Compare commits
5
Commits
9c987df6e9
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b66a4ed7 | ||
|
|
c5532f2087 | ||
|
|
f812e32270 | ||
|
|
fd79606052 | ||
|
|
efda8eb98f |
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 риски.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
*.tsbuildinfo
|
||||
dist/
|
||||
releases/
|
||||
src-tauri/target/
|
||||
|
||||
@@ -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 запущенными после проверки.
|
||||
|
||||
+6
-3
@@ -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.
|
||||
|
||||
|
||||
@@ -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,143 @@ 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
|
||||
.\release.cmd -Version 2.0.0 -Replace # пересобрать ещё не выпущенную версию с заменой тега
|
||||
```
|
||||
|
||||
Не меняйте исходники во время сборки. По умолчанию существующие теги не перезаписываются; при расхождении с удалённой веткой сценарий останавливается до изменения версий. При ошибке сборки изменения версии остаются локально для исправления, commit/tag/push не выполняются. При неудачном push готовая папка и локальный commit/tag сохраняются; `-Resume` проверяет исходники и SHA-256 перед повторной отправкой.
|
||||
|
||||
Если версия ещё не выложена пользователям, `-Version X.Y.Z -Replace` заново выполняет проверки и сборку с текущими изменениями. После сборки предыдущая папка сохраняется рядом как `proxywarden-vX.Y.Z-replaced-...`, а выбранный тег обновляется локально и в origin. История ветки сохраняется. Отправка использует `--force-with-lease` только для этого тега: если он изменился на сервере с начала операции, замена отклоняется. При сбое отправки используется обычный `-Version X.Y.Z -Resume`, который сохраняет первоначальное условие замены. `-Replace` требует явного номера версии и не совмещается с `-Resume`.
|
||||
|
||||
Для локальной подготовки без 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 +183,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.
|
||||
|
||||
@@ -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.
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
Generated
-2356
File diff suppressed because it is too large
Load Diff
@@ -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%
|
||||
@@ -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 @{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = @"
|
||||
<service>
|
||||
<id>$Name</id>
|
||||
<name>ProxyWarden Local sing-box</name>
|
||||
<description>Local sing-box runtime managed by ProxyWarden.</description>
|
||||
<executable>%BASE%\sing-box.exe</executable>
|
||||
<arguments>run -c "%BASE%\config.json"</arguments>
|
||||
<logpath>%BASE%\logs</logpath>
|
||||
<log mode="roll-by-size">
|
||||
<sizeThreshold>10485760</sizeThreshold>
|
||||
<keepFiles>4</keepFiles>
|
||||
</log>
|
||||
<onfailure action="restart" delay="5 sec"/>
|
||||
</service>
|
||||
"@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
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);
|
||||
const replacement = f.release("-Version", "1.2.0", "-Replace", "-PlanOnly");
|
||||
assert.equal(replacement.status, 0, replacement.stderr);
|
||||
const replacementPlan = JSON.parse(replacement.stdout);
|
||||
assert.equal(replacementPlan.changed, false);
|
||||
assert.equal(replacementPlan.details.replace, true);
|
||||
assert.equal(
|
||||
replacementPlan.details.git.replaceOnlyVersionTagWithLease,
|
||||
true,
|
||||
);
|
||||
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"), "");
|
||||
});
|
||||
|
||||
for (const remoteOnly of [false, true]) {
|
||||
test(`replacement rebuilds the same version and preserves the old folder (remote-only tag: ${remoteOnly})`, (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
||||
const oldTag = f.git("rev-parse", "refs/tags/v1.2.1");
|
||||
const oldCommit = f.git("rev-parse", "HEAD");
|
||||
const oldManifest = f.manifest();
|
||||
if (remoteOnly) f.git("tag", "-d", "v1.2.1");
|
||||
f.write("feature.txt", "updated before publishing");
|
||||
|
||||
const result = f.release("-Version", "1.2.1", "-Replace");
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
const newTag = f.git("rev-parse", "refs/tags/v1.2.1");
|
||||
assert.notEqual(newTag, oldTag);
|
||||
assert.equal(
|
||||
f.git("rev-parse", "v1.2.1^{commit}"),
|
||||
f.git("rev-parse", "HEAD"),
|
||||
);
|
||||
assert.equal(
|
||||
f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0],
|
||||
newTag,
|
||||
);
|
||||
assert.equal(f.git("rev-parse", "HEAD~1"), oldCommit);
|
||||
assert.equal(f.manifest().gitRelease.previousRemoteTag, oldTag);
|
||||
assert.equal(f.manifest().gitRelease.status, "pushed");
|
||||
const backups = readdirSync(join(f.repo, "releases")).filter((name) =>
|
||||
name.startsWith("proxywarden-v1.2.1-replaced-"),
|
||||
);
|
||||
assert.equal(backups.length, 1);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
join(f.repo, "releases", backups[0], "release-manifest.json"),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
oldManifest,
|
||||
);
|
||||
assert.equal(
|
||||
readFileSync(
|
||||
join(
|
||||
f.repo,
|
||||
"releases",
|
||||
backups[0],
|
||||
"artifacts/nsis/ProxyWarden_1.2.1_x64-setup.exe",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
"test artifact",
|
||||
);
|
||||
assert.equal(f.git("status", "--porcelain"), "");
|
||||
});
|
||||
}
|
||||
|
||||
test("failed replacement build preserves the previous release and refs", (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
"if ($Replace) { throw 'Synthetic replacement build failure' }",
|
||||
);
|
||||
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
||||
const oldManifest = f.manifest();
|
||||
const oldRefs = f.git("show-ref");
|
||||
f.write("feature.txt", "work in progress");
|
||||
const result = f.release("-Version", "1.2.1", "-Replace");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /Synthetic replacement build failure/);
|
||||
assert.equal(f.git("show-ref"), oldRefs);
|
||||
assert.deepEqual(f.manifest(), oldManifest);
|
||||
assert.deepEqual(readdirSync(join(f.repo, "releases")), [
|
||||
"proxywarden-v1.2.1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a failed replacement push resumes using the original tag lease", (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
||||
const oldRefs = f.git("ls-remote", "origin");
|
||||
f.write("feature.txt", "replacement");
|
||||
const hook = join(f.remote, "hooks/pre-receive");
|
||||
writeFileSync(hook, "#!/bin/sh\nexit 1\n");
|
||||
const result = f.release("-Version", "1.2.1", "-Replace");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(f.manifest().gitRelease.status, "pending-push");
|
||||
assert.equal(f.git("ls-remote", "origin"), oldRefs);
|
||||
const replacementTag = f.git("rev-parse", "refs/tags/v1.2.1");
|
||||
rmSync(hook);
|
||||
const resumed = f.release("-Version", "1.2.1", "-Resume");
|
||||
assert.equal(resumed.status, 0, resumed.stdout + resumed.stderr);
|
||||
assert.equal(f.manifest().gitRelease.status, "pushed");
|
||||
assert.equal(
|
||||
f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0],
|
||||
replacementTag,
|
||||
);
|
||||
const repeat = f.release("-Version", "1.2.1", "-Resume");
|
||||
assert.equal(repeat.status, 0, repeat.stdout + repeat.stderr);
|
||||
});
|
||||
|
||||
test("replacement never forces the branch when it advances during the build", (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
`if ($Replace) {
|
||||
$otherCommit = 'Concurrent remote commit' | & git commit-tree 'HEAD^{tree}' -p HEAD
|
||||
Invoke-Git @('push', 'origin', "${"$"}{otherCommit}:refs/heads/master") | Out-Null
|
||||
}`,
|
||||
);
|
||||
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
||||
const oldTag = f.git("ls-remote", "origin", "refs/tags/v1.2.1");
|
||||
f.write("feature.txt", "replacement");
|
||||
const result = f.release("-Version", "1.2.1", "-Replace");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /non-fast-forward|fetch first/);
|
||||
const remoteBranch = f
|
||||
.git("ls-remote", "origin", "refs/heads/master")
|
||||
.split(/\s/)[0];
|
||||
assert.notEqual(remoteBranch, f.git("rev-parse", "HEAD"));
|
||||
assert.equal(
|
||||
f.git("show", "-s", "--format=%s", remoteBranch),
|
||||
"Concurrent remote commit",
|
||||
);
|
||||
assert.equal(f.git("ls-remote", "origin", "refs/tags/v1.2.1"), oldTag);
|
||||
});
|
||||
|
||||
test("a concurrent remote tag change is preserved, including on Resume", (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
`if ($Replace) {
|
||||
$otherCommit = Invoke-Git @('rev-parse', 'HEAD~1')
|
||||
Invoke-Git @('--git-dir', (Join-Path $RepoRoot '../origin.git'), 'update-ref', 'refs/tags/v1.2.1', $otherCommit) | Out-Null
|
||||
}`,
|
||||
);
|
||||
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
||||
const oldBranch = f.git("ls-remote", "origin", "refs/heads/master");
|
||||
const concurrentTag = f.git("rev-parse", "HEAD~1");
|
||||
f.write("feature.txt", "replacement");
|
||||
const result = f.release("-Version", "1.2.1", "-Replace");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /stale info/);
|
||||
const resumed = f.release("-Version", "1.2.1", "-Resume");
|
||||
assert.notEqual(resumed.status, 0);
|
||||
assert.match(resumed.stderr, /stale info/);
|
||||
assert.equal(
|
||||
f.git("ls-remote", "origin", "refs/tags/v1.2.1").split(/\s/)[0],
|
||||
concurrentTag,
|
||||
);
|
||||
assert.equal(f.git("ls-remote", "origin", "refs/heads/master"), oldBranch);
|
||||
});
|
||||
|
||||
test("a concurrent local tag change is not overwritten by replacement", (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
"if ($Replace) { Invoke-Git @('tag', '-f', 'v1.2.1', 'HEAD~1') | Out-Null }",
|
||||
);
|
||||
assert.equal(f.release("-Version", "1.2.1").status, 0);
|
||||
const oldRemote = f.git("ls-remote", "origin");
|
||||
const concurrentTag = f.git("rev-parse", "HEAD~1");
|
||||
f.write("feature.txt", "replacement");
|
||||
const result = f.release("-Version", "1.2.1", "-Replace");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /Local version tag changed/);
|
||||
assert.equal(f.git("rev-parse", "refs/tags/v1.2.1"), concurrentTag);
|
||||
assert.equal(f.git("ls-remote", "origin"), oldRemote);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
+336
-22
@@ -1,4 +1,4 @@
|
||||
param(
|
||||
param(
|
||||
[string]$Version = "",
|
||||
[ValidateSet("", "patch", "minor", "major")]
|
||||
[string]$Bump = "",
|
||||
@@ -6,9 +6,13 @@ param(
|
||||
[switch]$SkipTests,
|
||||
[switch]$SkipBuild,
|
||||
[switch]$PlanOnly,
|
||||
[switch]$Publish,
|
||||
[switch]$Resume,
|
||||
[switch]$Replace,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
@@ -16,7 +20,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 +151,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 +217,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 +227,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 +238,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 +263,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 +294,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 +330,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 = "(?<value>[^"]+)"' -GroupName "value" -Value $TargetVersion -Label "ProxyWarden version in Cargo.lock"
|
||||
Write-Utf8NoBomFile -Path $CargoLockPath -Value $lock
|
||||
}
|
||||
|
||||
function Get-FullPath {
|
||||
@@ -354,12 +374,20 @@ function New-ReleaseDirectory {
|
||||
$releaseDir = Join-Path $root "proxywarden-v$TargetVersion"
|
||||
|
||||
if (Test-Path -LiteralPath $releaseDir) {
|
||||
if (-not $Replace -and ($Publish -or -not $Force)) { throw "Release directory already exists: $releaseDir. Use -Version $TargetVersion -Replace to rebuild an unreleased version, or -Resume to retry its push." }
|
||||
if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) {
|
||||
throw "Refusing to remove release directory outside OutputRoot: $releaseDir"
|
||||
throw "Refusing to replace release directory outside OutputRoot: $releaseDir"
|
||||
}
|
||||
if ($Replace) {
|
||||
$backupDir = "$releaseDir-replaced-$(Get-Date -Format 'yyyyMMdd-HHmmss')-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
|
||||
if (-not (Test-IsSubPath -Parent $root -Child $backupDir)) { throw 'Release backup must stay inside OutputRoot.' }
|
||||
Move-Item -LiteralPath $releaseDir -Destination $backupDir
|
||||
Write-Host "Предыдущая сборка сохранена: $backupDir"
|
||||
} else {
|
||||
Write-Host "Replacing existing release directory: $releaseDir"
|
||||
Remove-Item -LiteralPath $releaseDir -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path (Join-Path $releaseDir "artifacts") -Force | Out-Null
|
||||
$releaseDir
|
||||
@@ -412,17 +440,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 +565,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 +573,197 @@ 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"
|
||||
$localTag = if (Test-GitTag $tag) { Invoke-Git @('rev-parse', "refs/tags/$tag") } else { '' }
|
||||
if (-not $Resume -and -not $Replace -and $localTag) { throw "Tag $tag already exists. Use -Version $TargetVersion -Replace to rebuild an unreleased version, -Resume to retry its push, or choose another version." }
|
||||
$remoteTag = Invoke-Git @('ls-remote', '--refs', '--tags', 'origin', "refs/tags/$tag")
|
||||
if (-not $Resume -and -not $Replace -and $remoteTag) { throw "Remote tag $tag already exists. Use -Version $TargetVersion -Replace to rebuild an unreleased version, or choose another version." }
|
||||
$remoteTagId = if ($remoteTag) { ($remoteTag -split '\s+')[0] } else { '' }
|
||||
$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; replace = [bool]$Replace; previousLocalTag = $localTag; previousRemoteTag = $remoteTagId }
|
||||
}
|
||||
|
||||
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.'
|
||||
}
|
||||
$localTag = if (Test-GitTag $Context.tag) { Invoke-Git @('rev-parse', "refs/tags/$($Context.tag)") } else { '' }
|
||||
if ($Context.replace -and $localTag -ne $Context.previousLocalTag -and
|
||||
(-not $Resume -or -not $localTag -or (Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit)) {
|
||||
throw 'Local version tag changed during the release. Replacement refused.'
|
||||
}
|
||||
if ($localTag) {
|
||||
if ((Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit) {
|
||||
if (-not $Context.replace) { throw 'Existing tag points to another commit.' }
|
||||
Invoke-Git @('tag', '-a', '-f', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null
|
||||
}
|
||||
} else {
|
||||
Invoke-Git @('tag', '-a', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null
|
||||
}
|
||||
$tagObject = Invoke-Git @('rev-parse', "refs/tags/$($Context.tag)")
|
||||
$pushArgs = @('push', '--atomic')
|
||||
if ($Context.replace) {
|
||||
# Lease only this tag, never the branch. Keep the original expectation across Resume.
|
||||
$pushArgs += "--force-with-lease=refs/tags/$($Context.tag):$($Context.previousRemoteTag)"
|
||||
}
|
||||
$pushArgs += @('origin', "${Commit}:refs/heads/$($Context.branch)", "${tagObject}:refs/tags/$($Context.tag)")
|
||||
Invoke-Git $pushArgs | 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.' }
|
||||
}
|
||||
if ($manifest.gitRelease.PSObject.Properties['replace'] -and $manifest.gitRelease.replace) {
|
||||
$Context.replace = $true
|
||||
$Context.previousLocalTag = $manifest.gitRelease.previousLocalTag
|
||||
$Context.previousRemoteTag = $manifest.gitRelease.previousRemoteTag
|
||||
}
|
||||
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 +782,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 +810,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 +840,34 @@ function New-PlanResult {
|
||||
releaseDirectory = (Join-Path $outputRootFull "proxywarden-v$Target")
|
||||
skipTests = [bool]$SkipTests
|
||||
skipBuild = [bool]$SkipBuild
|
||||
publish = [bool]$Publish
|
||||
resume = [bool]$Resume
|
||||
replace = [bool]$Replace
|
||||
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
|
||||
replaceOnlyVersionTagWithLease = [bool]$Replace
|
||||
preservePreviousReleaseDirectory = [bool]$Replace
|
||||
}
|
||||
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 +876,16 @@ 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 ($Replace -and (-not $Publish -or -not $Version -or $Bump -or $Resume)) { throw '-Replace requires -Publish -Version X.Y.Z and cannot be combined with -Resume or -Bump.' }
|
||||
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
|
||||
@@ -624,6 +902,28 @@ try {
|
||||
Write-Host ""
|
||||
Write-Host "Preparing ProxyWarden release $targetVersion..."
|
||||
Write-Host "Repository: $RepoRoot"
|
||||
if ($Replace) { Write-Host "Пересборка невыпущенного релиза v$targetVersion с заменой тега. Предыдущая папка будет сохранена рядом." }
|
||||
|
||||
$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 -not $Replace -and ($Publish -or -not $Force)) {
|
||||
throw "Release directory already exists: $releasePath. Use -Version $targetVersion -Replace to rebuild an unreleased version, or -Resume to retry its push."
|
||||
}
|
||||
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
|
||||
@@ -631,6 +931,7 @@ try {
|
||||
throw "Version update failed. Current version is $afterUpdateVersion."
|
||||
}
|
||||
|
||||
$sourceTree = if ($Publish) { Get-SourceTree } else { $null }
|
||||
Invoke-ReleaseBuild
|
||||
|
||||
$releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion
|
||||
@@ -638,11 +939,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'; replace = $gitContext.replace; previousLocalTag = $gitContext.previousLocalTag; previousRemoteTag = $gitContext.previousRemoteTag }
|
||||
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
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
Generated
+45
-1
@@ -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"
|
||||
|
||||
+21
-1
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
|
||||
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
In addition, no derivative work may use the name or imply association
|
||||
with this application without prior consent.
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,25 +41,18 @@ impl SingBoxAdapter {
|
||||
where
|
||||
C: SingBoxConfigChecker + ?Sized,
|
||||
{
|
||||
let selected_server = request
|
||||
.config
|
||||
.selected_server_id
|
||||
.as_deref()
|
||||
.and_then(|id| {
|
||||
let selected_server = if let Some(id) = request.config.selected_server_id.as_deref() {
|
||||
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)
|
||||
})
|
||||
} 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,
|
||||
@@ -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)
|
||||
struct TemporaryConfig(std::path::PathBuf);
|
||||
impl Drop for TemporaryConfig {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
let _temporary = TemporaryConfig(config_path.clone());
|
||||
crate::safe_fs::write_restricted_atomic(&config_path, config_json.as_bytes()).map_err(
|
||||
|_| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
"Не удалось безопасно создать временный конфиг sing-box",
|
||||
)
|
||||
},
|
||||
)?;
|
||||
// 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 '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
if error.kind() == std::io::ErrorKind::TimedOut {
|
||||
"Проверка sing-box превысила 30 секунд"
|
||||
} else {
|
||||
"Не удалось выполнить проверку sing-box"
|
||||
},
|
||||
)
|
||||
})?;
|
||||
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()
|
||||
),
|
||||
));
|
||||
if !status.success() {
|
||||
return Err(SingBoxConfigError::new(SingBoxConfigErrorKind::CheckFailed,
|
||||
"sing-box отклонил конфигурацию выбранного сервера. Обновите подписку или выберите другой сервер."));
|
||||
}
|
||||
}
|
||||
|
||||
let output = command_no_window(binary_path)
|
||||
.arg("check")
|
||||
.arg("-c")
|
||||
.arg(&config_path)
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
let _ = fs::remove_file(&config_path);
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!(
|
||||
"Не удалось выполнить '{} check': {error}",
|
||||
binary_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
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(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!("Проверка sing-box не прошла: {message}"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(SingBoxCheckResult {
|
||||
checked: true,
|
||||
success: true,
|
||||
message: if message.is_empty() {
|
||||
"Проверка sing-box прошла успешно".to_string()
|
||||
} else {
|
||||
message
|
||||
},
|
||||
message: "Проверка sing-box прошла успешно".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -295,23 +268,30 @@ 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
|
||||
.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(|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
|
||||
})
|
||||
.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,
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
|
||||
+5
-71
@@ -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,
|
||||
"Перезапуск от имени администратора отменен или не был запущен.",
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
+142
-213
@@ -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<String>,
|
||||
pub route_mode: ApplyRouteMode,
|
||||
pub profile: ProfileInput,
|
||||
pub external_target: Option<TargetInput>,
|
||||
#[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<crate::command_dto::SavedStateResponse>,
|
||||
pub success: bool,
|
||||
pub changed: bool,
|
||||
pub partial_state: bool,
|
||||
@@ -128,6 +131,19 @@ pub fn apply_configuration(
|
||||
input: ApplyConfigurationInput,
|
||||
services: ApplyServices<'_>,
|
||||
) -> Result<ApplyConfigurationResult, ApplyFlowError> {
|
||||
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);
|
||||
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,
|
||||
));
|
||||
}
|
||||
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::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,
|
||||
));
|
||||
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))?;
|
||||
}
|
||||
phases.push(phase(
|
||||
"singbox-config",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
"Generated sing-box config записан; служба не перезапускалась.",
|
||||
));
|
||||
} 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 {
|
||||
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,
|
||||
}) {
|
||||
Ok(result) if result.success => result,
|
||||
Ok(result) => {
|
||||
return Ok(rollback_after_failure(
|
||||
&rollback_state,
|
||||
})
|
||||
.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,10 +355,17 @@ 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 {
|
||||
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(
|
||||
@@ -362,7 +373,19 @@ fn prepare_apply(
|
||||
"Для external маршрута требуется SOCKS5 target.",
|
||||
)
|
||||
})?;
|
||||
let target = normalize_target(target_input).map_err(ApplyFlowError::validation)?;
|
||||
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
|
||||
@@ -386,14 +409,7 @@ fn prepare_apply(
|
||||
services
|
||||
.singbox_adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(
|
||||
&config,
|
||||
&cache,
|
||||
services
|
||||
.detected_singbox
|
||||
.as_ref()
|
||||
.map(|detected| detected.executable_path.as_path()),
|
||||
),
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
services.checker,
|
||||
)
|
||||
.map_err(|error| {
|
||||
@@ -401,12 +417,10 @@ fn prepare_apply(
|
||||
})?,
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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: 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<ApplyPhase>,
|
||||
) -> 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<ApplyPhase>,
|
||||
) -> 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
|
||||
}
|
||||
|
||||
+300
-11
@@ -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<crate::route_state::ArtifactStatus>,
|
||||
pub revision: String,
|
||||
pub profiles: Vec<ProfileDto>,
|
||||
pub targets: Vec<TargetDto>,
|
||||
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<ComponentStatusDto>,
|
||||
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<u32>,
|
||||
pub outcome: String,
|
||||
pub changed: bool,
|
||||
pub blocking: bool,
|
||||
pub notice_code: Option<String>,
|
||||
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<String>,
|
||||
pub percent: u8,
|
||||
pub message: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
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<SubscriptionCacheDto>,
|
||||
pub component: ComponentStatusDto,
|
||||
@@ -356,6 +366,285 @@ pub struct ComponentStatusDto {
|
||||
pub actions: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub bundled_version: String,
|
||||
pub available_offline_version: String,
|
||||
pub latest_known_version: Option<String>,
|
||||
pub last_checked_at: Option<u64>,
|
||||
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<String>,
|
||||
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<String>,
|
||||
pub current_version: Option<String>,
|
||||
pub bundled_version: Option<String>,
|
||||
pub original_service_state: Option<ComponentCutoverServiceStateDto>,
|
||||
pub legacy_path_label: Option<String>,
|
||||
pub current_path_label: Option<String>,
|
||||
pub steps: Vec<String>,
|
||||
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<String>,
|
||||
pub disabled_message: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Self, Self::Error> {
|
||||
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<UpdateFreshness> for ComponentUpdateFreshnessDto {
|
||||
fn from(value: UpdateFreshness) -> Self {
|
||||
match value {
|
||||
UpdateFreshness::NeverChecked => Self::NeverChecked,
|
||||
UpdateFreshness::Fresh => Self::Fresh,
|
||||
UpdateFreshness::Stale => Self::Stale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ComponentUpdateState> 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<ComponentInstallSource> 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<PackageSource> for ComponentPackageSourceDto {
|
||||
fn from(value: PackageSource) -> Self {
|
||||
match value {
|
||||
PackageSource::Bundled => Self::Bundled,
|
||||
PackageSource::Cache => Self::Cache,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UpdateCheckTrust> 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 {
|
||||
|
||||
+1638
-87
File diff suppressed because it is too large
Load Diff
@@ -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<ComponentPackage>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub product_version: Option<String>,
|
||||
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<Vec<String>>,
|
||||
},
|
||||
BuildTimeOnlyAuthenticode {
|
||||
allowed_source_hosts: Vec<String>,
|
||||
asset_pattern: String,
|
||||
publishers: Vec<String>,
|
||||
},
|
||||
BundledOnlyNoIndependentProof {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn parse_catalog(bytes: &[u8]) -> Result<ComponentCatalog, ComponentCatalogError> {
|
||||
let catalog: ComponentCatalog = serde_json::from_slice(bytes)?;
|
||||
validate_catalog(&catalog)?;
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
pub fn validate_bundle(root: &Path) -> Result<ComponentCatalog, ComponentCatalogError> {
|
||||
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<Option<ComponentCatalog>, 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<Vec<&str>, 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<Url, ComponentCatalogError> {
|
||||
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<Vec<String>>,
|
||||
) -> 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<HashSet<String>, 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<String, ComponentCatalogError> {
|
||||
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<String>) -> ComponentCatalogError {
|
||||
ComponentCatalogError::Invalid(message.into())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1232
-241
File diff suppressed because it is too large
Load Diff
@@ -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<String>,
|
||||
pub executable_path: Option<PathBuf>,
|
||||
pub path_matches_candidate: bool,
|
||||
pub binary_version: Option<String>,
|
||||
}
|
||||
|
||||
#[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<PathBuf>,
|
||||
pub missing_files: Vec<PathBuf>,
|
||||
pub marker: MarkerEvidence,
|
||||
pub marker_required: bool,
|
||||
pub binary_identity: BinaryIdentityEvidence,
|
||||
pub binary_version: Option<String>,
|
||||
pub service: Option<ServiceEvidence>,
|
||||
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<String>, message: impl Into<String>) -> 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<PathBuf>,
|
||||
pub binary_version: Option<String>,
|
||||
pub service: Option<ServiceEvidence>,
|
||||
pub marker: MarkerEvidence,
|
||||
pub issues: Vec<InventoryIssue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ComponentInventory {
|
||||
pub component_id: ComponentId,
|
||||
pub candidates: Vec<ComponentCandidate>,
|
||||
pub selected: Option<usize>,
|
||||
pub issues: Vec<InventoryIssue>,
|
||||
}
|
||||
|
||||
/// 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<LegacyProxifyreScmProfile>,
|
||||
/// 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<String>,
|
||||
pub load_order_group: Option<String>,
|
||||
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<String>,
|
||||
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<E> {
|
||||
Denied(InventoryIssue),
|
||||
Runner(E),
|
||||
}
|
||||
|
||||
pub fn classify_component_candidates(
|
||||
component_id: ComponentId,
|
||||
probes: Vec<ComponentCandidateProbe>,
|
||||
) -> 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<Option<&ComponentCandidate>, 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<LegacyCutoverProof, InventoryIssue> {
|
||||
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<T, E>(
|
||||
inventory: &ComponentInventory,
|
||||
action: InventoryAction,
|
||||
runner: impl FnOnce(Option<&ComponentCandidate>) -> Result<T, E>,
|
||||
) -> Result<T, AuthorizedActionError<E>> {
|
||||
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<LegacyComponentIdentity, InventoryIssue> {
|
||||
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<T, E>(
|
||||
expected: &LegacyComponentIdentity,
|
||||
inventory: &ComponentInventory,
|
||||
action: InventoryAction,
|
||||
runner: impl FnOnce(&ComponentCandidate) -> Result<T, E>,
|
||||
) -> Result<T, AuthorizedActionError<E>> {
|
||||
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::<Vec<_>>();
|
||||
candidates.sort_by_key(Value::to_string);
|
||||
let mut issues = inventory
|
||||
.issues
|
||||
.iter()
|
||||
.map(|issue| issue.code.clone())
|
||||
.collect::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<Vec<String>> {
|
||||
if value.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
let characters: Vec<char> = 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::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("Установка неполна; отсутствуют: {names}")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Vec<ComponentStatus>, CommandError> {
|
||||
components_or_defaults_with_detection(
|
||||
storage,
|
||||
detect_proxyfier_install(),
|
||||
detect_singbox_install(),
|
||||
)
|
||||
pub(crate) fn live_components() -> Vec<ComponentStatus> {
|
||||
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<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Result<Vec<ComponentStatus>, CommandError> {
|
||||
let components = storage.read_components().map_err(storage_error)?;
|
||||
Ok(resolve_component_statuses(
|
||||
components,
|
||||
detected_proxyfier,
|
||||
detected_singbox,
|
||||
))
|
||||
) -> Vec<ComponentStatus> {
|
||||
resolve_component_statuses(detected_proxyfier, detected_singbox)
|
||||
}
|
||||
|
||||
pub fn resolve_component_statuses(
|
||||
stored_components: Vec<ComponentStatus>,
|
||||
detected_proxyfier: Option<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Vec<ComponentStatus> {
|
||||
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<ComponentStatus> {
|
||||
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<ComponentStatus> {
|
||||
vec![
|
||||
ComponentStatus {
|
||||
@@ -150,7 +151,3 @@ pub(crate) fn resolved_app(item: &ProfileItem, warnings: &mut Vec<String>) -> Re
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_error(error: std::io::Error) -> CommandError {
|
||||
CommandError::new("storage_error", error.to_string())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
//! 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<RootGuard> {
|
||||
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<RootGuard> {
|
||||
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<PathBuf> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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<String> {
|
||||
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<Option<String>>,
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
// Old elevated versions left some readable files owned by Administrators.
|
||||
// An unchanged file is already restored; rewriting it can fail and strand
|
||||
// an otherwise complete rollback, blocking every subsequent guarded read.
|
||||
if optional_bytes(path)? == bytes {
|
||||
continue;
|
||||
}
|
||||
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<RootGuard>,
|
||||
intent: Intent,
|
||||
committed: bool,
|
||||
}
|
||||
impl<'a> ConfigurationTransaction<'a> {
|
||||
pub fn begin(storage: &'a JsonStorage, expected: Option<&str>) -> io::Result<Self> {
|
||||
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<String> {
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
@@ -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<StatusResponse, CommandError> {
|
||||
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<StatusResponse, CommandErro
|
||||
}
|
||||
|
||||
pub fn read_profiles(storage: &JsonStorage) -> Result<Vec<ProfileDto>, 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<ProfileDto, CommandError> {
|
||||
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<Vec<TargetDto>, 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<TargetDto, CommandError> {
|
||||
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,38 +114,146 @@ 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<Vec<ComponentStatusDto>, CommandError> {
|
||||
components_or_defaults(storage).map(|components| {
|
||||
components
|
||||
pub fn read_live_components() -> Vec<ComponentStatusDto> {
|
||||
live_components()
|
||||
.iter()
|
||||
.map(ComponentStatusDto::from)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.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<StartupSnapshotResponse, CommandError> {
|
||||
// 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(
|
||||
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(),
|
||||
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,
|
||||
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();
|
||||
@@ -147,6 +262,7 @@ pub fn read_startup_snapshot(
|
||||
&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<Vec<ActivityEntryDto>, Com
|
||||
}
|
||||
|
||||
pub fn read_saved_state(storage: &JsonStorage) -> Result<SavedStateResponse, CommandError> {
|
||||
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<SavedStateResponse, CommandError> {
|
||||
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<Profile>,
|
||||
targets: Vec<Target>,
|
||||
}
|
||||
|
||||
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<ImportedSavedState> {
|
||||
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::<Vec<_>>();
|
||||
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<ProfileItem>,
|
||||
protocols: Vec<Protocol>,
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
fn import_proxy_entry(proxy: &ProxiFyreProxy) -> Option<ImportedProxyEntry> {
|
||||
let items = proxy
|
||||
.app_names
|
||||
.iter()
|
||||
.filter_map(|name| imported_profile_item(name))
|
||||
.collect::<Vec<_>>();
|
||||
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<ProfileItem> {
|
||||
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<Protocol> {
|
||||
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::<u16>().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::<u16>().ok()?;
|
||||
(!host.is_empty()).then(|| (host.to_string(), port))
|
||||
}
|
||||
|
||||
fn upsert_targets(targets: &mut Vec<Target>, imported_targets: Vec<Target>) {
|
||||
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<ResolveProfilePreviewResponse, CommandError> {
|
||||
@@ -431,3 +347,89 @@ fn validation_error(errors: Vec<ValidationError>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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<ComponentId>,
|
||||
#[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<String>,
|
||||
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<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait HelperCommandRunner {
|
||||
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StructuredHelper<R> {
|
||||
helper_program: PathBuf,
|
||||
runner: R,
|
||||
}
|
||||
|
||||
impl<R> StructuredHelper<R>
|
||||
where
|
||||
R: HelperCommandRunner,
|
||||
{
|
||||
pub fn new(helper_program: impl Into<PathBuf>, runner: R) -> Self {
|
||||
Self {
|
||||
helper_program: helper_program.into(),
|
||||
runner,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runner(&self) -> &R {
|
||||
&self.runner
|
||||
}
|
||||
|
||||
pub fn execute(&self, request: &HelperRequest) -> Result<HelperResponse, HelperError> {
|
||||
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<HelperResponse, HelperError> {
|
||||
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<Path>,
|
||||
service_name: impl Into<String>,
|
||||
) -> 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
|
||||
)
|
||||
}
|
||||
+125
-6
@@ -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<I>(arguments: I) -> EarlyProcessMode
|
||||
where
|
||||
I: IntoIterator<Item = std::ffi::OsString>,
|
||||
{
|
||||
let arguments = arguments.into_iter().collect::<Vec<_>>();
|
||||
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<I>(
|
||||
arguments: I,
|
||||
resolver: &dyn privileged_jobs::PrivilegedPlanResolver,
|
||||
runner: &dyn privileged_jobs::PrivilegedActionRunner,
|
||||
) -> EarlyProcessMode
|
||||
where
|
||||
I: IntoIterator<Item = std::ffi::OsString>,
|
||||
{
|
||||
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::<std::ffi::OsString>::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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+26
-23
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Vec<&'static str>>,
|
||||
verify_executable: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
lifecycle_state: VecDeque<Result<NsisLifecycleState, NsisRuntimeError>>,
|
||||
cutover: VecDeque<Result<NsisCutoverState, NsisRuntimeError>>,
|
||||
proxifyre: VecDeque<Result<NsisComponentState, NsisRuntimeError>>,
|
||||
singbox: VecDeque<Result<NsisComponentState, NsisRuntimeError>>,
|
||||
transients: VecDeque<Result<NsisTransientState, NsisRuntimeError>>,
|
||||
acquire: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
retire: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
stop_proxifyre: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
stop_singbox: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
retry_singbox_cleanup: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
uninstall_proxifyre: VecDeque<Result<bool, NsisRuntimeError>>,
|
||||
uninstall_singbox: VecDeque<Result<bool, NsisRuntimeError>>,
|
||||
reboot_under_lock: VecDeque<Result<bool, NsisRuntimeError>>,
|
||||
mark_reboot: VecDeque<Result<bool, NsisRuntimeError>>,
|
||||
clear_reboot: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
cleanup: VecDeque<Result<(), NsisRuntimeError>>,
|
||||
}
|
||||
|
||||
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<T>(queue: &mut VecDeque<Result<T, NsisRuntimeError>>) -> Result<T, NsisRuntimeError> {
|
||||
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<NsisLifecycleState, NsisRuntimeError> {
|
||||
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<NsisCutoverState, NsisRuntimeError> {
|
||||
self.call("cutover");
|
||||
next(&mut self.cutover)
|
||||
}
|
||||
|
||||
fn preflight_proxifyre(&mut self) -> Result<NsisComponentState, NsisRuntimeError> {
|
||||
self.call("proxifyre");
|
||||
next(&mut self.proxifyre)
|
||||
}
|
||||
|
||||
fn preflight_singbox(&mut self) -> Result<NsisComponentState, NsisRuntimeError> {
|
||||
self.call("singbox");
|
||||
next(&mut self.singbox)
|
||||
}
|
||||
|
||||
fn verify_transient_layout(&mut self) -> Result<NsisTransientState, NsisRuntimeError> {
|
||||
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<bool, NsisRuntimeError> {
|
||||
self.call("uninstall-proxifyre");
|
||||
next(&mut self.uninstall_proxifyre)
|
||||
}
|
||||
|
||||
fn uninstall_singbox(&mut self) -> Result<bool, NsisRuntimeError> {
|
||||
self.call("uninstall-singbox");
|
||||
next(&mut self.uninstall_singbox)
|
||||
}
|
||||
|
||||
fn reboot_required_under_lock(&mut self) -> Result<bool, NsisRuntimeError> {
|
||||
self.call("reboot-under-lock");
|
||||
next(&mut self.reboot_under_lock)
|
||||
}
|
||||
|
||||
fn mark_reboot_required(&mut self) -> Result<bool, NsisRuntimeError> {
|
||||
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::<OsString>::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());
|
||||
}
|
||||
}
|
||||
@@ -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<Output> {
|
||||
command_no_window("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script,
|
||||
])
|
||||
.output()
|
||||
}
|
||||
|
||||
pub(crate) fn run_file(script_path: &Path) -> std::io::Result<Output> {
|
||||
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::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
const MAX_CHARS: usize = 1400;
|
||||
if text.chars().count() <= MAX_CHARS {
|
||||
return text;
|
||||
}
|
||||
|
||||
format!("{}...", text.chars().take(MAX_CHARS).collect::<String>())
|
||||
}
|
||||
|
||||
pub(crate) fn escape_single(value: &str) -> String {
|
||||
value.replace('\'', "''")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+3941
-1
File diff suppressed because it is too large
Load Diff
@@ -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<ManagedProxiFyreOwnership, String> {
|
||||
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<ProxiFyreInstallMarker, String> {
|
||||
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<std::path::PathBuf, String> {
|
||||
fs::canonicalize(path)
|
||||
.map_err(|error| format!("не удалось проверить {label} '{}': {error}", path.display()))
|
||||
|
||||
+4568
-1021
File diff suppressed because it is too large
Load Diff
@@ -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<Call>,
|
||||
fail_on: Option<Call>,
|
||||
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<ServiceRestoreSnapshot, ProxifyreNativeHostError> {
|
||||
self.record(Call::CaptureLegacy)?;
|
||||
Ok(self.before.clone())
|
||||
}
|
||||
|
||||
fn query_legacy_service(&mut self) -> Result<ServicePolicySnapshot, ProxifyreNativeHostError> {
|
||||
self.record(Call::QueryLegacy)?;
|
||||
Ok(Self::missing_policy())
|
||||
}
|
||||
|
||||
fn query_current_service(&mut self) -> Result<ServicePolicySnapshot, ProxifyreNativeHostError> {
|
||||
self.record(Call::QueryCurrent)?;
|
||||
Ok(Self::missing_policy())
|
||||
}
|
||||
|
||||
fn query_complete_service(
|
||||
&mut self,
|
||||
) -> Result<CompleteServiceObservation, ProxifyreNativeHostError> {
|
||||
self.record(Call::QueryComplete)?;
|
||||
Ok(self.complete.clone())
|
||||
}
|
||||
|
||||
fn expected_current_service_base(
|
||||
&self,
|
||||
) -> Result<ServiceBaseConfigSnapshot, ProxifyreNativeHostError> {
|
||||
Ok(self.current_base.clone())
|
||||
}
|
||||
|
||||
fn query_legacy_service_policy(
|
||||
&mut self,
|
||||
kind: ServiceConfig2Kind,
|
||||
) -> Result<Option<ServiceConfig2Snapshot>, ProxifyreNativeHostError> {
|
||||
self.record(Call::QueryLegacyPolicy(kind))?;
|
||||
Ok(self.before.config2(kind).cloned())
|
||||
}
|
||||
|
||||
fn query_current_service_policy(
|
||||
&mut self,
|
||||
kind: ServiceConfig2Kind,
|
||||
) -> Result<Option<ServiceConfig2Snapshot>, ProxifyreNativeHostError> {
|
||||
self.record(Call::QueryCurrentPolicy(kind))?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn current_service_security_matches(&mut self) -> Result<bool, ProxifyreNativeHostError> {
|
||||
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<CandidateCall>,
|
||||
fail_on: Option<CandidateCall>,
|
||||
fail_after_effect: Option<CandidateCall>,
|
||||
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<ProxifyreCutoverCandidateObservation, ProxifyreNativeHostError> {
|
||||
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<u8>,
|
||||
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<u8>,
|
||||
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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 '<invalid-url>'
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<H = SystemProxyfierDetectionHost> {
|
||||
host: H,
|
||||
current_root: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
|
||||
@@ -68,7 +74,19 @@ impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
|
||||
|
||||
impl<H> From<H> for DetectedProxyApplyHelper<H> {
|
||||
fn from(host: H) -> Self {
|
||||
Self { host }
|
||||
Self {
|
||||
host,
|
||||
current_root: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<H> DetectedProxyApplyHelper<H> {
|
||||
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<HelperApplyResult, CommandError> {
|
||||
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>) -> 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<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||
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(),
|
||||
})?;
|
||||
|
||||
let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result);
|
||||
storage
|
||||
.append_activity(activity.clone())
|
||||
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);
|
||||
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<HelperApplyResult, CommandError> {
|
||||
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<HelperApplyResult, CommandError> {
|
||||
@@ -204,6 +226,20 @@ fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result<HelperApplyRes
|
||||
})
|
||||
}
|
||||
|
||||
fn staged_managed_current_result(
|
||||
request: HelperApplyRequest<'_>,
|
||||
) -> Result<HelperApplyResult, CommandError> {
|
||||
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,
|
||||
|
||||
@@ -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<PreparedArtifact>,
|
||||
singbox: Option<PreparedArtifact>,
|
||||
}
|
||||
|
||||
#[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<String> {
|
||||
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<Vec<ArtifactStatus>> {
|
||||
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<Vec<ArtifactStatus>> {
|
||||
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<process::ServiceInstance>,
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
+4713
-21
File diff suppressed because it is too large
Load Diff
@@ -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<C>(
|
||||
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)?;
|
||||
|
||||
let activity = activity_for_singbox_generate(clock, &generated, &generated_path);
|
||||
storage
|
||||
.append_activity(activity.clone())
|
||||
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);
|
||||
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 {
|
||||
|
||||
+1310
-479
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+105
-166
@@ -1,30 +1,119 @@
|
||||
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<String>,
|
||||
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<Self> {
|
||||
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!(
|
||||
"<service>\r\n",
|
||||
" <id>ProxyWardenSingBox</id>\r\n",
|
||||
" <name>ProxyWarden Local sing-box</name>\r\n",
|
||||
" <description>Local sing-box runtime managed by ProxyWarden</description>\r\n",
|
||||
" <executable>%BASE%\\sing-box.exe</executable>\r\n",
|
||||
" <arguments>run -c "%BASE%\\config.json"</arguments>\r\n",
|
||||
" <startmode>Manual</startmode>\r\n",
|
||||
" <onfailure action=\"none\" />\r\n",
|
||||
" <logpath>%BASE%\\..\\..\\.proxywarden-service-logs\\sing-box</logpath>\r\n",
|
||||
" <log mode=\"none\"/>\r\n",
|
||||
"</service>\r\n",
|
||||
)
|
||||
}
|
||||
|
||||
fn quote_windows_executable(path: &Path) -> Option<String> {
|
||||
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)]
|
||||
@@ -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<String>,
|
||||
pub status: Option<String>,
|
||||
pub process_id: Option<u32>,
|
||||
}
|
||||
|
||||
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<ServiceCommandOutput> {
|
||||
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('\'', "''")
|
||||
}
|
||||
|
||||
@@ -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<LocalSingBoxStatusResponse, CommandError> {
|
||||
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<LocalSingBoxStatusResponse, CommandError> {
|
||||
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<LocalSingBoxStatusResponse, CommandError> {
|
||||
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<LocalSingBoxStatusResponse, CommandError> {
|
||||
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<LocalSingBoxStatusResponse, CommandError> {
|
||||
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 {
|
||||
|
||||
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!("Серверов найдено: {}", cache.servers.len()),
|
||||
})
|
||||
.map_err(storage_error)?;
|
||||
|
||||
read_singbox_status(storage)
|
||||
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<LocalSingBoxStatusResponse, CommandError> {
|
||||
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<LocalSingBoxStatusResponse, CommandError> {
|
||||
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<SubscriptionCache, CommandError> {
|
||||
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<u16>,
|
||||
) -> Option<&'a SubscriptionServer> {
|
||||
requested_id
|
||||
.and_then(|id| cache.servers.iter().find(|server| server.id == id))
|
||||
.or_else(|| {
|
||||
cache
|
||||
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()
|
||||
.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
|
||||
})
|
||||
})
|
||||
.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 {
|
||||
|
||||
+147
-24
@@ -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<Vec<ComponentStatus>> {
|
||||
self.read_json_or_default(&self.paths.components_file)
|
||||
}
|
||||
|
||||
pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
|
||||
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<Option<StorageMeta>> {
|
||||
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<Option<ComponentLayoutMeta>> {
|
||||
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<Option<ComponentCutoverObservation>> {
|
||||
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<Option<ComponentCutoverUserEvidence>> {
|
||||
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<Option<SubscriptionCache>> {
|
||||
let mut cache = self
|
||||
.read_optional_json::<SubscriptionCache>(&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<Vec<ActivityEntry>> {
|
||||
let entries = self.read_json_or_default(&self.paths.activity_file)?;
|
||||
Ok(cap_activity(entries, self.activity_limit))
|
||||
@@ -148,10 +269,20 @@ impl JsonStorage {
|
||||
Ok(contents) => {
|
||||
parse_json(path, &contents).or_else(|error| recover_corrupt_json(path, error))
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_json<T>(&self, path: &Path, value: &T) -> io::Result<()>
|
||||
where
|
||||
@@ -206,23 +337,21 @@ fn recover_corrupt_json<T>(path: &Path, parse_error: io::Error) -> io::Result<T>
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SubscriptionServer> {
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
+129
-414
@@ -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<String>,
|
||||
registry: Vec<RegistryInstallEntry>,
|
||||
service_paths: HashMap<String, String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
None
|
||||
self.service_paths
|
||||
.contains_key(&_service_name.to_ascii_lowercase())
|
||||
.then(|| "stopped".to_string())
|
||||
}
|
||||
|
||||
fn service_info(
|
||||
&self,
|
||||
service_name: &str,
|
||||
) -> Option<proxywarden_lib::component_detection::DetectedService> {
|
||||
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<RegistryInstallEntry> {
|
||||
self.registry.clone()
|
||||
}
|
||||
|
||||
fn read_text(&self, path: &Path) -> Option<String> {
|
||||
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<T: serde::Serialize + ?Sized>(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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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#"<service>
|
||||
<!-- <id>ProxyWardenSingBox</id> -->
|
||||
<!-- <executable>%BASE%\sing-box.exe</executable> -->
|
||||
<metadata><arguments>run -c "%BASE%\config.json"</arguments></metadata>
|
||||
<id>ForeignService</id>
|
||||
<executable>C:\Foreign\sing-box.exe</executable>
|
||||
<arguments>run -c "C:\Foreign\config.json"</arguments>
|
||||
</service>"#;
|
||||
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!(
|
||||
"<service><id>ProxyWardenSingBox</id><executable>%BASE%\\sing-box.exe</executable><arguments>run -c \"%BASE%\\config.json\"</arguments><description>{}</description></service>",
|
||||
"x".repeat(65 * 1024)
|
||||
);
|
||||
let invalid_xml = vec![
|
||||
r#"<service><id>ProxyWardenSingBox</id><id>ProxyWardenSingBox</id><executable>%BASE%\sing-box.exe</executable><arguments>run -c "%BASE%\config.json"</arguments></service>"#.to_string(),
|
||||
r#"<!DOCTYPE service [<!ENTITY owned "ProxyWardenSingBox">]><service><id>&owned;</id><executable>%BASE%\sing-box.exe</executable><arguments>run -c "%BASE%\config.json"</arguments></service>"#.to_string(),
|
||||
r#"<service><id><![CDATA[ProxyWardenSingBox]]></id><executable>%BASE%\sing-box.exe</executable><arguments>run -c "%BASE%\config.json"</arguments></service>"#.to_string(),
|
||||
format!("{}<service></service>", 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}<?xml version=\"1.0\" encoding=\"utf-8\"?><service><id>ProxyWardenSingBox</id><name>ProxyWarden Local sing-box</name><executable>%BASE%\\sing-box.exe</executable><arguments>run -c \"%BASE%\\config.json\"</arguments><log mode=\"roll-by-size\"><keepFiles>4</keepFiles></log><onfailure action=\"restart\" /></service>".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::<Vec<_>>();
|
||||
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::<u32>().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<String, String>,
|
||||
@@ -214,6 +558,9 @@ struct MockHost {
|
||||
processes: HashSet<String>,
|
||||
services: HashMap<String, String>,
|
||||
service_paths: HashMap<String, String>,
|
||||
texts: HashMap<String, String>,
|
||||
known_binaries: HashSet<String>,
|
||||
versions: HashMap<String, String>,
|
||||
registry: Vec<RegistryInstallEntry>,
|
||||
}
|
||||
|
||||
@@ -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,93 @@ impl ProxyfierDetectionHost for MockHost {
|
||||
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||
self.registry.clone()
|
||||
}
|
||||
|
||||
fn read_text(&self, path: &Path) -> Option<String> {
|
||||
self.texts
|
||||
.get(&normalize_path(&path.display().to_string()))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn file_version(&self, path: &Path) -> Option<String> {
|
||||
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#"<service>
|
||||
<id>ProxyWardenSingBox</id>
|
||||
<executable>%BASE%\sing-box.exe</executable>
|
||||
<arguments>run -c "%BASE%\config.json"</arguments>
|
||||
</service>"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_singbox_recognizes_the_native_installer_xml() {
|
||||
let xml = proxywarden_lib::singbox_service::singbox_service_xml();
|
||||
for xml in [
|
||||
xml.to_owned(),
|
||||
xml.replace(""", """),
|
||||
xml.replace(""", """),
|
||||
] {
|
||||
let inventory = inventory_singbox_with_host(¤t_singbox_host_with_xml(&xml));
|
||||
assert_eq!(
|
||||
inventory.classification(),
|
||||
ComponentClassification::ManagedCurrent
|
||||
);
|
||||
}
|
||||
for entity in ["&unknown;", "&quot;", "�", "�"] {
|
||||
let xml = xml.replace(""", entity);
|
||||
let inventory = inventory_singbox_with_host(¤t_singbox_host_with_xml(&xml));
|
||||
assert_ne!(
|
||||
inventory.classification(),
|
||||
ComponentClassification::ManagedCurrent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore = "read-only smoke test requiring an installed managed sing-box"]
|
||||
fn installed_singbox_inventory_is_current() {
|
||||
let inventory = proxywarden_lib::component_detection::inventory_singbox();
|
||||
assert_eq!(
|
||||
inventory.classification(),
|
||||
ComponentClassification::ManagedCurrent,
|
||||
"{inventory:?}"
|
||||
);
|
||||
}
|
||||
|
||||
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""#,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<LegacyCutoverProof, InventoryIssue>) {
|
||||
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<ServiceEvidence>,
|
||||
) -> 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()),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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 rollback_leaves_unchanged_files_in_place() {
|
||||
let fixture = Fixture::new();
|
||||
let path = &fixture.storage.paths().profiles_file;
|
||||
let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap();
|
||||
// Deny replacement on Windows, while allowing the recovery code to read.
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
options.share_mode(1);
|
||||
}
|
||||
let held = options.open(path).unwrap();
|
||||
let modified = held.metadata().unwrap().modified().unwrap();
|
||||
transaction.abort().unwrap();
|
||||
assert_eq!(fs::metadata(path).unwrap().modified().unwrap(), modified);
|
||||
assert!(read_guard(&fixture.storage).is_ok());
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
@@ -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<Path>) -> 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<Profile> =
|
||||
serde_json::from_value(read_json(root.join("pre-1.2-split/config/profiles.json")))
|
||||
.expect("expected profiles");
|
||||
let expected_targets: Vec<Target> =
|
||||
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<Profile> =
|
||||
serde_json::from_value(read_json(root.join("pre-1.2-split/config/profiles.json")))
|
||||
.expect("legacy profiles fixture");
|
||||
let targets: Vec<Target> =
|
||||
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<ComponentStatus> =
|
||||
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<Profile>, Vec<Target>), &'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::<u16>()
|
||||
.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<PathBuf>) {
|
||||
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()
|
||||
}
|
||||
+445
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component": "proxyfier",
|
||||
"packagePath": "C:\\Fixture\\Packages\\proxifyre-package.zip",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installedAt": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installRoot": "C:\\Fixture\\ProxyWarden\\components\\ProxiFyre",
|
||||
"packetFilterInstalledByProxyWarden": false
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user