Compare commits
10
Commits
9fd0a8c0b9
..
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b66a4ed7 | ||
|
|
c5532f2087 | ||
|
|
f812e32270 | ||
|
|
fd79606052 | ||
|
|
efda8eb98f | ||
|
|
9c987df6e9 | ||
|
|
90ec4ca086 | ||
|
|
67792f245f | ||
|
|
90b2eb507c | ||
|
|
dbba3806cc |
@@ -47,6 +47,21 @@ src/app/components/SingBoxWorkspace.tsx
|
||||
- Use `aria-*` for tabs, toggle buttons, popovers, service controls.
|
||||
- Honor reduced motion where relevant.
|
||||
|
||||
## Animated disclosures
|
||||
|
||||
- Keep disclosure content mounted through opening and closing so both directions can animate. Do not conditionally render content directly into its final open state.
|
||||
- Keep the trigger at one screen position and separate layout placement from hover/active transforms.
|
||||
- Gate hidden content with `aria-hidden` plus `inert` or `tabIndex`; opacity and `pointer-events` alone do not remove controls from keyboard navigation.
|
||||
- Keep `aria-expanded` and `aria-controls` on the trigger synchronized with the rendered state.
|
||||
- Use one motion origin and timeline for background, copy, and actions. Implement and verify the reverse transition at the same time as the entrance.
|
||||
|
||||
## Startup responsiveness
|
||||
|
||||
- Render the shell and saved/default configuration immediately. Do not gate first paint on network access, subscription refresh, or every component probe.
|
||||
- Show slow component detection in reserved `checking` geometry and apply partial results as they arrive without replaying page entrance motion.
|
||||
- Do not serialize independent probes to create a staged UI. When the backend exposes only an aggregate snapshot, animate reserved placeholders and replace their values in place when that snapshot arrives.
|
||||
- Keep navigation and already-known configuration usable while background detection continues.
|
||||
|
||||
## Proxy/routing UI
|
||||
|
||||
When editing route UI:
|
||||
@@ -71,6 +86,7 @@ Recommended for extracted pure logic:
|
||||
- Unit tests for readiness states.
|
||||
- Unit tests for snapshot diff/change dock model.
|
||||
- UI smoke checks for desktop and narrow layout.
|
||||
- For hover, disclosure, or motion changes, exercise first open, close, repeated toggle, hover during transition, keyboard focus, loading copy, and `prefers-reduced-motion`. Build and unit tests do not validate these behaviors.
|
||||
|
||||
## Do not
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,14 @@ pub async fn some_command(input: SomeInput) -> Result<SomeOutput, CommandError>
|
||||
|
||||
Implementation function должна быть тестируемой без Tauri runtime, если возможно.
|
||||
|
||||
## Startup responsiveness
|
||||
|
||||
- Keep first paint independent from network access, subscription refresh, and slow component detection.
|
||||
- Run independent startup probes concurrently and outside the async runtime thread. Do not serialize ProxiFyre, sing-box, service, and admin checks without a dependency between them.
|
||||
- Give external or process-heavy probes a bounded timeout and return partial status when one probe is slow or unavailable.
|
||||
- Load saved configuration and other cheap state first. Let the UI render it while detection results update separately or through a partial startup snapshot.
|
||||
- Do not fail the entire startup snapshot because one optional component cannot be detected. Preserve structured per-component errors or unknown/checking state.
|
||||
|
||||
## DTO boundary
|
||||
|
||||
При добавлении или изменении command:
|
||||
@@ -89,6 +97,7 @@ services/singbox_service.rs
|
||||
- `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
- `cargo test --all-targets`
|
||||
- Relevant Windows/manual check if touching service/install/elevation.
|
||||
- When changing startup aggregation, verify that one delayed or failed probe does not postpone unrelated saved state or component results.
|
||||
|
||||
Если `cargo` недоступен в среде, честно написать, что backend проверен только статически. Не изображать компилятор, у него и так тяжелая жизнь.
|
||||
|
||||
|
||||
@@ -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,95 +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:
|
||||
`PlanOnly`/`CheckOnly` должны возвращать structured JSON с `changed: false` и не менять repo, ProgramData, services или network state.
|
||||
|
||||
```powershell
|
||||
& .\scripts\install-control-app.ps1 -PlanOnly
|
||||
& .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||
& .\scripts\install-singbox.ps1 -PlanOnly
|
||||
```
|
||||
## Interaction smoke for UI motion
|
||||
|
||||
## CI recommendation
|
||||
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 не выполнен, так и напиши.
|
||||
|
||||
Add GitHub Actions with at least:
|
||||
## CI contract
|
||||
|
||||
- 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.
|
||||
Windows baseline должен включать:
|
||||
|
||||
- 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.
|
||||
|
||||
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 риски.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# `cargo run` / `tauri dev` should control the components installed by ProxyWarden,
|
||||
# not copies that happen to exist beside target\debug\proxywarden.exe.
|
||||
[env]
|
||||
PROXYWARDEN_DEV_INSTALL_ROOT = { value = 'C:\Program Files\ProxyWarden', force = false }
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: design-proxywarden-ui
|
||||
description: Design, implement, review, or refine ProxyWarden UI using the shared calm monospace VPN-client language: centered state control, green-tinted neutrals, route-aware accents, stable geometry, and smooth state-driven motion. Use for React components, CSS, service controls, routing views, tooltips, status transitions, and responsive polish in this repository.
|
||||
---
|
||||
|
||||
# Design ProxyWarden UI
|
||||
|
||||
Keep ProxyWarden a compact Windows utility while matching the visual language of the sibling VPN client.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read `AGENTS.md`, `.agent/skills/react-typescript-ui/SKILL.md`, and the complete component and CSS being changed.
|
||||
2. Read [visual-language.md](references/visual-language.md) for composition, typography, color, and surfaces.
|
||||
3. Read [motion-and-interaction.md](references/motion-and-interaction.md) for state and interaction animation.
|
||||
4. Before editing a disclosure or motion-heavy control, write a compact storyboard for `collapsed`, `opening`, `open`, and `closing`: fixed elements, origin, direction, duration, easing, focus, and reduced-motion behavior.
|
||||
5. Reuse `src/ui/*`, existing state, CSS tokens, and typed Tauri boundaries. Prefer CSS and narrow markup changes over dependencies or new abstractions.
|
||||
6. Keep geometry stable across loading, success, error, copy, refresh, and route changes.
|
||||
7. Add `prefers-reduced-motion` behavior with every new animation.
|
||||
8. After a second user correction to the same interaction, stop stacking overrides. Re-read its markup and styles, restate the latest behavior, remove superseded assumptions, and rebuild the motion model cleanly.
|
||||
9. Run `npm test`, `npm run build`, and an interaction smoke for visible motion or hit-target changes. Check desktop and narrow layouts; build and lint never substitute for visual verification.
|
||||
|
||||
## Non-negotiable decisions
|
||||
|
||||
- Preserve explicit install, start, stop, uninstall, and apply actions. Styling must not blur operational meaning.
|
||||
- Keep the summary read-only except for its existing service power action; do not add configuration mutations there.
|
||||
- Render the primary power action as a generous invisible hit target around the icon, not a large filled accent circle.
|
||||
- Use the blue-green accent for ready/active routing and orange only for direct/local-route distinction. Keep warnings and errors semantic.
|
||||
- Prefer open composition, quiet surface shifts, and localized light over dashboard cards, thick borders, and decorative chrome.
|
||||
- Animate opacity, blur, glow, color, filter, and transform; never animate layout properties or use `transition: all`.
|
||||
- Keep interactive triggers at one screen position throughout disclosure motion. Use layout for resting placement, never a transform that hover or active feedback can overwrite.
|
||||
- Make transient prompts independent overlays; they must not add shell height or move the main workspace.
|
||||
- Keep labels, paths, status copy, spinners, and feedback in reserved geometry so neighboring content does not move.
|
||||
- Keep tooltips independent from transformed, rotating, glowing, or filtered controls.
|
||||
- Keep secrets and credential-bearing URLs redacted in every visual state.
|
||||
- Keep narrow layouts single-column and keyboard focus visible.
|
||||
- Do not call a motion task complete without checking open, close, repeated toggle, hover during transition, keyboard focus, and reduced motion. If the state cannot be reproduced, report the missing visual evidence explicitly.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Motion and interaction
|
||||
|
||||
## Character
|
||||
|
||||
Use fluid, slightly viscous motion that makes work and state legible without moving layout. Avoid bounce, elastic easing, abrupt unmounts, and decorative page choreography.
|
||||
|
||||
Use `cubic-bezier(0.16, 1, 0.3, 1)` for arrivals and interaction feedback.
|
||||
|
||||
- hover and press: 180-300ms;
|
||||
- popover/tooltip: 90-180ms;
|
||||
- panel reveal: 420-600ms;
|
||||
- state color and glow: 600-900ms;
|
||||
- progress or numeric tween: about 900ms.
|
||||
|
||||
## State controls
|
||||
|
||||
- Transition inactive gray to the route accent slowly when a service becomes active, and back to gray when stopped.
|
||||
- Animate icon color, localized light, and SVG shadow together while keeping the hit target fixed.
|
||||
- Use a short `scale(0.97)` press followed by a slower release.
|
||||
- Show checking and running work with restrained motion that finishes cleanly; do not stop spinners or cycles at arbitrary coordinates.
|
||||
|
||||
## Changing content
|
||||
|
||||
- Crossfade alternate labels inside one fixed slot. Do not replace text in normal flow when its length can move the interface.
|
||||
- Animate only what changed. Unchanged labels, icons, surrounding rows, and route nodes stay fixed.
|
||||
- Update data immediately when it arrives; finishing a decorative cycle must not delay the result.
|
||||
- Repeated background polling updates quietly and does not replay entrance choreography.
|
||||
- Keep mode selectors outside the keyed content they replace. Let the new content enter with a short directional fade and blur while focus remains on the selected mode.
|
||||
- For user-triggered sorting, fade and lightly blur the reordered list as one surface; row stagger stays bounded and saved data order does not change.
|
||||
|
||||
## Anchored disclosures
|
||||
|
||||
- Keep the trigger fixed while its surface opens and closes. Position its resting hit area with grid, flex, or logical inset properties; never rely on a placement `transform` that hover or active feedback can replace.
|
||||
- Give the surface, background, copy, and actions one origin and one timeline. They should emerge from the trigger together; do not make the background pop before the trigger or appear after the content.
|
||||
- Keep animated disclosure content mounted through entry and exit. Gate pointer and keyboard access separately; conditional rendering directly into the final state is not an entrance animation.
|
||||
- Design opening and closing together. Preserve visible reverse motion long enough before fading opacity, and keep both directions interruptible under repeated clicks.
|
||||
- Let explicit product feedback override the default easing. When a component calls for a slow start followed by acceleration, define a local curve instead of forcing the global ease-out.
|
||||
- Compose hover and active feedback without changing the resting position. If transform composition is unavoidable, use separate wrappers, individual transform properties, or shared custom properties and verify every state.
|
||||
- Keep decorative sweeps subordinate to state motion, low-opacity, bounded to the surface, and finished cleanly. The disclosure must remain legible without the effect.
|
||||
|
||||
## Lists and disclosures
|
||||
|
||||
- Reveal dynamic rows with opacity, light blur, and a small transform.
|
||||
- On hover, let a row lift one or two pixels and reveal a restrained local surface/light; keep resting rows visually flat.
|
||||
- Animate status dots through color, light, and a small scale change instead of animating a surrounding badge or border.
|
||||
- Keep departing rows and disclosures mounted until their exit animation completes; remove immediately under reduced motion.
|
||||
- Bound list stagger to 60-100ms and never make interaction latency grow with list length.
|
||||
- Tooltips appear quickly above the trigger as independent translucent surfaces and never inherit trigger transforms or filters.
|
||||
|
||||
## Reduced motion
|
||||
|
||||
Under `prefers-reduced-motion: reduce`, remove transforms, filters, transitions, and keyframes while preserving final state, focus, contrast, status wording, and all functionality.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Visual language
|
||||
|
||||
## Character
|
||||
|
||||
Design for a Windows user opening a small control surface to check routing, recover a service, or apply one deliberate configuration change. The UI should feel soft, precise, dependable, and slightly terminal-like, not like a network administration dashboard.
|
||||
|
||||
## Composition
|
||||
|
||||
- Make current system state and the next safe action dominant.
|
||||
- Keep the summary power control visually centered and pair it with a compact vertical route chain.
|
||||
- Use open space, typography, subtle surface shifts, localized light, and state color before frames or dividers.
|
||||
- Keep service rows compact: status, human-readable detail, one primary action, then secondary actions.
|
||||
- Preserve the existing tabs and operational grouping; visual consistency does not justify moving ownership or hiding actions.
|
||||
|
||||
## Typography and geometry
|
||||
|
||||
- Use JetBrains Mono with uppercase tracked micro-labels only for metadata.
|
||||
- Use weight and color before large size jumps. Use tabular numerals for changing values.
|
||||
- Reserve equal space for mutually exclusive labels and feedback.
|
||||
- Use 8px controls, 10px surfaces, and pills only for status tokens.
|
||||
- Keep icon-only hit areas at least 40px and align icons in flex/grid rather than guessed offsets.
|
||||
|
||||
## Color and light
|
||||
|
||||
- Base dark surfaces on green-tinted OKLCH neutrals around hue 145.
|
||||
- Use blue-green `oklch(0.68 0.11 185)` as the primary active/focus accent.
|
||||
- Use orange `oklch(0.71 0.12 72)` for direct/local-route distinction, never as general decoration.
|
||||
- Keep warning/error colors semantic. Do not recolor destructive actions with the route accent.
|
||||
- Prefer localized `drop-shadow`, text glow, or a soft radial light layer over filled accent containers.
|
||||
- Keep inactive power neutral even on hover; color communicates state, not clickability alone.
|
||||
|
||||
## Surfaces and controls
|
||||
|
||||
- Use quiet translucent cloud surfaces for tooltips and transient overlays.
|
||||
- Inputs are inset and slightly darker than surrounding surfaces.
|
||||
- Avoid nested cards. Group related controls with spacing and one subtle surface shift.
|
||||
- Keep persistent work surfaces borderless by default. Use a border only when it communicates input focus, destructive confirmation, or another essential state.
|
||||
- Render statuses and counters as a glowing dot or quiet value plus text, not as bordered badge capsules.
|
||||
- Let service rows, route nodes, app rows, and server rows float on the shared canvas; reveal their surface only on hover, focus, selection, or active work.
|
||||
- Prefer a short luminous underline or localized glow for selection and keyboard focus over a rectangular focus frame.
|
||||
- Use shared `src/ui` primitives and preserve their default, hover, active, focus, disabled, loading, empty, and error states.
|
||||
|
||||
## Emphasis and border budget
|
||||
|
||||
- Give each compact surface one dominant accent at most. A transient warning action must not outshine the primary system state or its trigger.
|
||||
- Do not stack borders on the container, trigger, and action. Start with tonal background, spacing, and text hierarchy; keep persistent outlines for keyboard focus, destructive confirmation, or an otherwise ambiguous hit target.
|
||||
- Treat warm warning color as a restrained semantic tint, not decorative fill or a large glow. Adapt a shared `primary` button locally when its default emphasis conflicts with the surrounding prompt.
|
||||
- Validate the complete component, not isolated controls: resting, hover, focus, active, disabled, loading, open, and closed states must share one radius and emphasis language.
|
||||
|
||||
## Route checks
|
||||
|
||||
- Keep the route description, endpoint, and check action in a stable three-part row. Reserve the action width so mode changes and endpoint length never move the button.
|
||||
- Present the endpoint as the named route target, not as a detached badge or a second result.
|
||||
- Reveal a borderless result surface only while a check is running or after it completes. Show every returned probe in a structured table with separate status, external IP, and latency columns; do not compress unlike values into mixed badges or hardcode a fixed probe count.
|
||||
- Keep the summary short. Put verbose URLs, request methods, status codes, and errors in a calm structured detail cloud opened by hovering or focusing the result surface.
|
||||
- Animate result arrival and status light, while preserving the same geometry and honoring reduced motion.
|
||||
|
||||
## Route chain semantics
|
||||
|
||||
- Show only stages with distinct user-facing responsibilities. Never render both `Выход` and `SOCKS5 endpoint` when they describe the same destination.
|
||||
- Use `Приложения → ProxiFyre → SOCKS5` for the external-proxy route. End a direct route with `Интернет: напрямую` instead of an implementation-stage label.
|
||||
- Explain each stage in plain Russian for a non-technical user. Omit filesystem paths, ports, service names, and generated-config details unless the user explicitly asks for diagnostics.
|
||||
- Reserve the final chain height before revealing nodes. Progressive arrival may change opacity, blur, or transform, but must not reflow neighboring content.
|
||||
- Treat progressive arrival as a presentation sequence over reserved slots. Do not serialize independent backend probes just to match the animation; if the API returns one aggregate snapshot, show calm `checking` placeholders and replace them in place.
|
||||
- Reveal the initial chain in a short, legible sequence and do not replay it for background polling or quiet status refreshes.
|
||||
|
||||
## Admin elevation prompt
|
||||
|
||||
- Render the prompt as a fixed bottom-right overlay that never changes shell height or shifts the workspace. Offset it above persistent bottom docks instead of covering them.
|
||||
- Keep the collapsed trigger as a stationary 44px warm shield. Show a concise hint once per application session after admin status is known, then dismiss it automatically.
|
||||
- On click, expand the surface leftward from the shield while the shield stays in the same screen position. Keep the full row height tied to the trigger.
|
||||
- Reveal background, copy, and action from the same origin and timeline. Use a roughly 520-560ms slow-start opening and a visible 380-420ms reverse close; never delay the background until the end.
|
||||
- Keep any light pass subtle, local, and optional. It must not replace the actual surface/content motion.
|
||||
- Use the concise title `Нужны права администратора`, the reason `Для управления ProxiFyre и правилами Windows.`, and the action `Перезапустить`. Do not show paths or elevation internals.
|
||||
- Keep the surface and action borderless by default. Use a muted warm tint; the action must remain quieter than the shield and main system state.
|
||||
- Verify the Russian copy, `Открываю UAC`, hover, focus, repeated toggle, narrow width, and Windows text scaling without clipping or layout movement.
|
||||
|
||||
## Route modes and managed lists
|
||||
|
||||
- Present external and local proxy routes as two peer choices above the content they replace. Keep the chooser mounted while the mode body crossfades in from the selected direction.
|
||||
- Reserve the same configuration-stage height for both routes and place it before route diagnostics, so mode-specific labels and controls remain aligned even when check results expand.
|
||||
- Use the blue-green accent for the external route and the warm route accent for Local sing-box. A small status light and quiet surface shift are enough; do not add a long selection rule.
|
||||
- A green service light means running, not merely installed. Installed-without-service, stopped, and missing states remain warning-colored.
|
||||
- Hovering service and application rows reveals a neutral side marker and slight positional response. Do not place a green radial wash behind the entire row.
|
||||
- Application grouping is display-only. Preserve saved order as the default, provide explicit Processes, EXE files, and Folders sections with counts, and keep alphabetical sorting as a separate option. Remount only the visible list surface so changes can fade into place.
|
||||
@@ -26,6 +26,15 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check frontend formatting
|
||||
run: npm run format:check
|
||||
|
||||
- name: Run frontend lints
|
||||
run: npm run lint
|
||||
|
||||
- name: Check frontend types
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run frontend tests
|
||||
run: npm test -- --run
|
||||
|
||||
@@ -40,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
|
||||
@@ -47,14 +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 запущенными после проверки.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Участие в разработке ProxyWarden
|
||||
|
||||
ProxyWarden остается локальной Windows-утилитой. Изменения не должны превращать проект в VPN-провайдер, proxy server, SaaS или облачный control plane. Перед работой прочитайте `AGENTS.md` и релевантный skill из `.agent/skills`.
|
||||
|
||||
## Локальная проверка
|
||||
|
||||
```powershell
|
||||
npm ci
|
||||
npm run format:check
|
||||
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
|
||||
|
||||
npm run tauri -- info
|
||||
& .\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-тестам. Для таких изменений укажите выполненный ручной сценарий или явно оставьте этот пробел в отчете.
|
||||
|
||||
## Изменения
|
||||
|
||||
- Держите `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.
|
||||
|
||||
В pull request кратко опишите поведение, затронутые файлы, выполненные проверки и оставшиеся Windows/manual риски.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ProxyWarden 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.
|
||||
@@ -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,222 +39,178 @@ 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 должны показывать только редактированную/сокращенную версию ссылки.
|
||||
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
|
||||
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.
|
||||
- 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.
|
||||
@@ -0,0 +1,21 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist/**', 'src-tauri/**'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
document: 'readonly',
|
||||
HTMLElement: 'readonly',
|
||||
HTMLDivElement: 'readonly',
|
||||
requestAnimationFrame: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
window: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+1157
-2
File diff suppressed because it is too large
Load Diff
+14
-4
@@ -1,12 +1,18 @@
|
||||
{
|
||||
"name": "proxywarden",
|
||||
"version": "1.0.2",
|
||||
"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": "tsc && vite build",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"tauri": "tauri"
|
||||
@@ -20,12 +26,16 @@
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^10.7.0",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.2.4",
|
||||
"vite": "^7.0.0"
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
@@ -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%
|
||||
@@ -0,0 +1,211 @@
|
||||
param(
|
||||
[ValidateSet("PlanOnly", "Capture")]
|
||||
[string]$Mode = "PlanOnly",
|
||||
[string]$DataRoot = "C:\ProgramData\ProxyWarden",
|
||||
[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 = ""
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$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 8
|
||||
}
|
||||
|
||||
function Get-ServiceEvidence {
|
||||
param([string[]]$Names)
|
||||
|
||||
$result = @()
|
||||
foreach ($name in $Names | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) {
|
||||
$escaped = $name.Replace("'", "''")
|
||||
$service = Get-CimInstance Win32_Service -Filter "Name='$escaped'" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $service) {
|
||||
$result += [ordered]@{ name = $name; found = $false }
|
||||
continue
|
||||
}
|
||||
|
||||
$result += [ordered]@{
|
||||
name = $service.Name
|
||||
found = $true
|
||||
state = $service.State
|
||||
startMode = $service.StartMode
|
||||
pathName = $service.PathName
|
||||
processId = [int]$service.ProcessId
|
||||
}
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
function Test-PathUnderRoot {
|
||||
param([string]$Path, [string]$Root)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Root)) { return $false }
|
||||
$fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\')
|
||||
$fullRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\')
|
||||
return $fullPath.Equals($fullRoot, [StringComparison]::OrdinalIgnoreCase) -or
|
||||
$fullPath.StartsWith("$fullRoot\", [StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
function Get-ServiceExecutablePath {
|
||||
param([string]$PathName)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($PathName)) { return "" }
|
||||
$trimmed = $PathName.Trim()
|
||||
if ($trimmed.StartsWith('"')) {
|
||||
$closingQuote = $trimmed.IndexOf('"', 1)
|
||||
if ($closingQuote -gt 1) { return $trimmed.Substring(1, $closingQuote - 1) }
|
||||
}
|
||||
return ($trimmed -split '\s+', 2)[0]
|
||||
}
|
||||
|
||||
function Get-FileEvidence {
|
||||
param([string]$Root)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return @() }
|
||||
return @(
|
||||
Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Select-Object @{N="path";E={$_.FullName}}, @{N="length";E={$_.Length}}, @{N="lastWriteTimeUtc";E={$_.LastWriteTimeUtc.ToString("o")}}
|
||||
)
|
||||
}
|
||||
|
||||
function Get-SecretFindingCategories {
|
||||
param([string]$Root)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return @() }
|
||||
$patterns = [ordered]@{
|
||||
urlUserInfo = '://[^/\s"'']+@'
|
||||
credentialQuery = '(?i)[?&](token|key|auth|password|passwd|secret)=[^&\s"'']+'
|
||||
socksCredentials = '(?i)socks5://[^/\s:@]+:[^/\s@]+@'
|
||||
hwidHeader = '(?i)x-hwid[^\r\n]*[0-9a-f]{8}-[0-9a-f-]{27,}'
|
||||
}
|
||||
|
||||
$findings = @()
|
||||
$files = Get-ChildItem -LiteralPath $Root -Recurse -File -Include *.json,*.log,*.txt -ErrorAction SilentlyContinue
|
||||
foreach ($file in $files) {
|
||||
$content = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue
|
||||
if ($null -eq $content) { continue }
|
||||
foreach ($entry in $patterns.GetEnumerator()) {
|
||||
if ($content -match $entry.Value) {
|
||||
$findings += [ordered]@{ path = $file.FullName; category = $entry.Key }
|
||||
}
|
||||
}
|
||||
}
|
||||
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\components\sing-box\ProxyWardenSingBox.exe" -service'
|
||||
$quotedExecutable = Get-ServiceExecutablePath -PathName $quotedServiceFixture
|
||||
if (-not (Test-PathUnderRoot -Path $quotedExecutable -Root "C:\Program Files\ProxyWarden\components\sing-box")) {
|
||||
throw "Quoted service PathName ownership self-test failed."
|
||||
}
|
||||
|
||||
$plan = [ordered]@{
|
||||
mode = $Mode
|
||||
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", "internal-state-presence-only")
|
||||
}
|
||||
|
||||
if ($Mode -eq "PlanOnly") {
|
||||
New-Result -Success $true -Action "audit-windows-smoke.plan" -Changed $false -Message "Windows smoke evidence plan is ready." -Details $plan
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
$OutputPath = Join-Path $PWD ("audit-windows-smoke-{0}.json" -f (Get-Date -Format "yyyyMMdd-HHmmss"))
|
||||
}
|
||||
$outputFullPath = [IO.Path]::GetFullPath($OutputPath)
|
||||
$outputDirectory = Split-Path -Parent $outputFullPath
|
||||
if ([string]::IsNullOrWhiteSpace($outputDirectory)) { throw "OutputPath must include a writable directory." }
|
||||
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
|
||||
|
||||
$serviceNames = @("ProxiFyreService", "ProxyWardenSingBox", $ForeignServiceName)
|
||||
$services = @(Get-ServiceEvidence -Names $serviceNames)
|
||||
$ownership = @(
|
||||
$services | Where-Object found | ForEach-Object {
|
||||
$expectedRoot = switch ($_.name) {
|
||||
"ProxiFyreService" { $ProxiFyreRoot }
|
||||
"ProxyWardenSingBox" { $SingBoxRoot }
|
||||
default { "" }
|
||||
}
|
||||
[ordered]@{
|
||||
name = $_.name
|
||||
expectedManagedRoot = if ($expectedRoot) { [IO.Path]::GetFullPath($expectedRoot) } else { $null }
|
||||
pathUnderExpectedRoot = if ($expectedRoot) { Test-PathUnderRoot -Path (Get-ServiceExecutablePath -PathName $_.pathName) -Root $expectedRoot } else { $false }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
$report = [ordered]@{
|
||||
capturedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
computerName = $env:COMPUTERNAME
|
||||
os = (Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, OSArchitecture)
|
||||
services = $services
|
||||
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
|
||||
|
||||
New-Result -Success $true -Action "audit-windows-smoke.capture" -Changed $true -Message "Read-only Windows smoke evidence captured." -Details @{
|
||||
outputPath = $outputFullPath
|
||||
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 @{}
|
||||
exit 1
|
||||
}
|
||||
@@ -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:\Tools\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\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$|\\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
Generated
+46
-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,19 +2335,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxywarden"
|
||||
version = "1.0.2"
|
||||
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]]
|
||||
@@ -4846,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"
|
||||
|
||||
+22
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxywarden"
|
||||
version = "1.0.2"
|
||||
version = "2.0.0"
|
||||
description = "Standalone Windows desktop proxy management app for ProxyWarden."
|
||||
authors = ["ProxyWarden"]
|
||||
edition = "2021"
|
||||
@@ -22,6 +22,27 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
|
||||
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",
|
||||
] }
|
||||
|
||||
@@ -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/>.
|
||||
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.
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.
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
@@ -0,0 +1,35 @@
|
||||
!macro NSIS_HOOK_PREUNINSTALL
|
||||
${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,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),
|
||||
});
|
||||
}
|
||||
@@ -205,7 +209,10 @@ fn app_names_for_profile(profile: &Profile) -> Vec<String> {
|
||||
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value,
|
||||
};
|
||||
|
||||
if !names.iter().any(|existing| existing == app_name) {
|
||||
if !names
|
||||
.iter()
|
||||
.any(|existing: &String| existing.eq_ignore_ascii_case(app_name))
|
||||
{
|
||||
names.push(app_name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache};
|
||||
use crate::process::command_no_window;
|
||||
use crate::models::{LocalSingBoxConfig, SubscriptionCache, SubscriptionServer};
|
||||
use crate::process::run_fixed_process;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::Path,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
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";
|
||||
@@ -43,23 +39,29 @@ impl SingBoxAdapter {
|
||||
checker: &C,
|
||||
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
|
||||
where
|
||||
C: SingBoxConfigChecker,
|
||||
C: SingBoxConfigChecker + ?Sized,
|
||||
{
|
||||
let selected_server_tag = request
|
||||
.config
|
||||
.selected_server_tag
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
let selected_server = if let Some(id) = request.config.selected_server_id.as_deref() {
|
||||
request
|
||||
.subscription_cache
|
||||
.servers
|
||||
.iter()
|
||||
.find(|server| server.id == id)
|
||||
} else {
|
||||
let mut matches = request.subscription_cache.servers.iter().filter(|server| {
|
||||
Some(server.tag.as_str()) == request.config.selected_server_tag.as_deref()
|
||||
});
|
||||
matches.next().filter(|_| matches.next().is_none())
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedServer,
|
||||
"Сервер Local sing-box не выбран",
|
||||
"Сервер Local sing-box не выбран или отсутствует в текущей подписке",
|
||||
)
|
||||
})?;
|
||||
let vpn_outbound = selected_outbound(
|
||||
&request.subscription_cache.config,
|
||||
selected_server_tag,
|
||||
selected_server,
|
||||
&self.vpn_outbound_tag,
|
||||
)?;
|
||||
let generated_config = json!({
|
||||
@@ -105,7 +107,7 @@ impl SingBoxAdapter {
|
||||
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
|
||||
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
|
||||
contents,
|
||||
selected_server_tag: selected_server_tag.to_string(),
|
||||
selected_server_tag: selected_server.tag.clone(),
|
||||
listen: request.config.listen_host.clone(),
|
||||
listen_port: request.config.listen_port,
|
||||
check,
|
||||
@@ -200,64 +202,61 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
|
||||
config_json: &str,
|
||||
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||
let config_path = env::temp_dir().join(format!(
|
||||
"proxywarden-sing-box-{}-{}.json",
|
||||
std::process::id(),
|
||||
now_millis()
|
||||
"proxywarden-sing-box-{}.json",
|
||||
uuid::Uuid::new_v4().hyphenated()
|
||||
));
|
||||
|
||||
fs::write(&config_path, config_json).map_err(|error| {
|
||||
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,
|
||||
format!(
|
||||
"Не удалось записать временный конфиг sing-box '{}': {error}",
|
||||
config_path.display()
|
||||
),
|
||||
"Не удалось безопасно создать временный конфиг sing-box",
|
||||
)
|
||||
})?;
|
||||
|
||||
let output = command_no_window(binary_path)
|
||||
.arg("check")
|
||||
.arg("-c")
|
||||
.arg(&config_path)
|
||||
.output()
|
||||
},
|
||||
)?;
|
||||
// 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| {
|
||||
let _ = fs::remove_file(&config_path);
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!(
|
||||
"Не удалось выполнить '{} check': {error}",
|
||||
binary_path.display()
|
||||
),
|
||||
if error.kind() == std::io::ErrorKind::TimedOut {
|
||||
"Проверка sing-box превысила 30 секунд"
|
||||
} else {
|
||||
"Не удалось выполнить проверку sing-box"
|
||||
},
|
||||
)
|
||||
})?;
|
||||
let _ = fs::remove_file(&config_path);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let message = command_message(&stdout, &stderr);
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::CheckFailed,
|
||||
format!("Проверка sing-box не прошла: {message}"),
|
||||
));
|
||||
if !status.success() {
|
||||
return Err(SingBoxConfigError::new(SingBoxConfigErrorKind::CheckFailed,
|
||||
"sing-box отклонил конфигурацию выбранного сервера. Обновите подписку или выберите другой сервер."));
|
||||
}
|
||||
|
||||
Ok(SingBoxCheckResult {
|
||||
checked: true,
|
||||
success: true,
|
||||
message: if message.is_empty() {
|
||||
"Проверка sing-box прошла успешно".to_string()
|
||||
} else {
|
||||
message
|
||||
},
|
||||
message: "Проверка sing-box прошла успешно".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_outbound(
|
||||
subscription_config: &Value,
|
||||
selected_server_tag: &str,
|
||||
selected_server: &SubscriptionServer,
|
||||
vpn_outbound_tag: &str,
|
||||
) -> Result<Value, SingBoxConfigError> {
|
||||
let outbounds = subscription_config
|
||||
@@ -269,18 +268,37 @@ fn selected_outbound(
|
||||
"В cache подписки нет outbounds",
|
||||
)
|
||||
})?;
|
||||
let outbound = outbounds
|
||||
.iter()
|
||||
.find(|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)
|
||||
.is_some_and(|tag| {
|
||||
crate::models::decode_percent_encoded_utf8(tag).trim() == selected_server.tag
|
||||
})
|
||||
&& outbound.get("type").and_then(Value::as_str)
|
||||
== Some(selected_server.server_type.as_str())
|
||||
&& outbound
|
||||
.get("server")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|host| host.eq_ignore_ascii_case(&selected_server.server))
|
||||
&& outbound.get("server_port").and_then(Value::as_u64)
|
||||
== Some(u64::from(selected_server.server_port))
|
||||
});
|
||||
matches.next().filter(|_| matches.next().is_none())
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound,
|
||||
format!("Outbound не найден: {selected_server_tag}"),
|
||||
format!(
|
||||
"Outbound не найден: {} ({}:{})",
|
||||
selected_server.tag, selected_server.server, selected_server.server_port
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let outbound_type = outbound
|
||||
@@ -292,7 +310,8 @@ fn selected_outbound(
|
||||
return Err(SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!(
|
||||
"Outbound '{selected_server_tag}' имеет неподдерживаемый тип '{outbound_type}'"
|
||||
"Outbound '{}' имеет неподдерживаемый тип '{outbound_type}'",
|
||||
selected_server.tag
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -301,7 +320,10 @@ fn selected_outbound(
|
||||
let object = outbound.as_object_mut().ok_or_else(|| {
|
||||
SingBoxConfigError::new(
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound,
|
||||
format!("Outbound '{selected_server_tag}' должен быть JSON-объектом"),
|
||||
format!(
|
||||
"Outbound '{}' должен быть JSON-объектом",
|
||||
selected_server.tag
|
||||
),
|
||||
)
|
||||
})?;
|
||||
object.insert(
|
||||
@@ -317,22 +339,3 @@ fn selected_outbound(
|
||||
|
||||
Ok(outbound)
|
||||
}
|
||||
|
||||
fn now_millis() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Administrator-state detection and explicit UAC restart boundary.
|
||||
|
||||
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_process_elevated();
|
||||
let message = if !is_windows {
|
||||
"Проверка прав администратора нужна только в Windows.".to_string()
|
||||
} else if is_elevated {
|
||||
"ProxyWarden уже запущен от имени администратора.".to_string()
|
||||
} else {
|
||||
"Права администратора будут запрошены отдельно для выбранного действия.".to_string()
|
||||
};
|
||||
|
||||
AdminStatusResponse {
|
||||
is_windows,
|
||||
is_elevated,
|
||||
can_restart_elevated: false,
|
||||
message,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
//! Transactional configuration apply use case.
|
||||
//!
|
||||
//! The module validates and generates all artifacts before source writes,
|
||||
//! performs no service lifecycle actions, and attempts rollback when a later
|
||||
//! write or runtime apply fails.
|
||||
|
||||
use crate::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||
use crate::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxConfigChecker, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
|
||||
};
|
||||
use crate::clock::Clock;
|
||||
use crate::component_detection::{
|
||||
proxyfier_component_from_detection, singbox_component_from_detection, DetectedProxyfier,
|
||||
DetectedSingBox,
|
||||
};
|
||||
use crate::models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, LocalSingBoxConfig, Profile, ProfileInput,
|
||||
ProxyProtocol, Target, TargetInput, TargetKind,
|
||||
};
|
||||
use crate::proxy_apply::{HelperApplyRequest, ProxyApplyHelper};
|
||||
use crate::safe_fs;
|
||||
use crate::storage::JsonStorage;
|
||||
use crate::validation::{normalize_profile, normalize_target, ValidationError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
|
||||
const LOCAL_SINGBOX_TARGET_ID: &str = "local-singbox";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ApplyRouteMode {
|
||||
External,
|
||||
LocalSingbox,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub disable_other_profiles: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyPhase {
|
||||
pub id: String,
|
||||
pub status: ApplyPhaseStatus,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ApplyPhaseStatus {
|
||||
Succeeded,
|
||||
Failed,
|
||||
RolledBack,
|
||||
Skipped,
|
||||
Warning,
|
||||
}
|
||||
|
||||
#[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,
|
||||
pub message: String,
|
||||
pub error_code: Option<String>,
|
||||
pub generated_config_path: String,
|
||||
pub singbox_generated_config_path: Option<String>,
|
||||
pub restart_required: Vec<ComponentId>,
|
||||
pub phases: Vec<ApplyPhase>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApplyFlowError {
|
||||
#[error("Проверьте поля конфигурации")]
|
||||
Validation { details: Vec<ValidationError> },
|
||||
#[error("{message}")]
|
||||
Failure { code: String, message: String },
|
||||
}
|
||||
|
||||
impl ApplyFlowError {
|
||||
pub fn code(&self) -> &str {
|
||||
match self {
|
||||
Self::Validation { .. } => "validation_failed",
|
||||
Self::Failure { code, .. } => code,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn details(self) -> Vec<ValidationError> {
|
||||
match self {
|
||||
Self::Validation { details } => details,
|
||||
Self::Failure { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self::Failure {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validation(details: Vec<ValidationError>) -> Self {
|
||||
Self::Validation { details }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApplyServices<'a> {
|
||||
pub proxy_adapter: &'a dyn ProxyRouterAdapter,
|
||||
pub singbox_adapter: &'a SingBoxAdapter,
|
||||
pub checker: &'a dyn SingBoxConfigChecker,
|
||||
pub helper: &'a dyn ProxyApplyHelper,
|
||||
pub clock: &'a dyn Clock,
|
||||
pub detected_proxyfier: Option<DetectedProxyfier>,
|
||||
pub detected_singbox: Option<DetectedSingBox>,
|
||||
}
|
||||
|
||||
/// Applies one complete routing draft without starting, stopping, installing,
|
||||
/// uninstalling, or restarting Windows services.
|
||||
pub fn apply_configuration(
|
||||
storage: &JsonStorage,
|
||||
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()
|
||||
.map_err(|error| storage_error("profiles_read_failed", error))?;
|
||||
let old_targets = storage
|
||||
.read_targets()
|
||||
.map_err(|error| storage_error("targets_read_failed", error))?;
|
||||
|
||||
let PreparedApply {
|
||||
profiles,
|
||||
targets,
|
||||
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,
|
||||
"Входные данные и оба generated config проверены до записи.",
|
||||
));
|
||||
|
||||
let source_changed = profiles != old_profiles || targets != old_targets;
|
||||
let proxy_path = storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join(&proxy_config.output_file_name);
|
||||
let singbox_path = singbox_config
|
||||
.as_ref()
|
||||
.map(|_| storage.paths().generated_dir.join(SINGBOX_OUTPUT_FILE));
|
||||
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, &singbox_path) {
|
||||
safe_fs::write_restricted_with_backup(path, generated.contents.as_bytes())
|
||||
.map_err(|e| storage_error("singbox_config_write_failed", e))?;
|
||||
}
|
||||
safe_fs::write_restricted_with_backup(&proxy_path, proxy_config.contents.as_bytes())
|
||||
.map_err(|e| storage_error("proxifyre_config_write_failed", e))?;
|
||||
let result = services
|
||||
.helper
|
||||
.apply_proxy_config(HelperApplyRequest {
|
||||
adapter_id: &proxy_config.adapter_id,
|
||||
config_path: &proxy_path,
|
||||
config_contents: &proxy_config.contents,
|
||||
})
|
||||
.map_err(|e| ApplyFlowError::failure(e.code, e.message))?;
|
||||
if !result.success {
|
||||
return Err(ApplyFlowError::failure(
|
||||
"proxifyre_apply_failed",
|
||||
result.message,
|
||||
));
|
||||
}
|
||||
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) => {
|
||||
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(
|
||||
"service-control",
|
||||
ApplyPhaseStatus::Skipped,
|
||||
"Apply не управляет службами.",
|
||||
));
|
||||
let mut restart_required = Vec::new();
|
||||
if services.detected_proxyfier.is_some() {
|
||||
restart_required.push(ComponentId::Proxyfier);
|
||||
}
|
||||
if singbox_config.is_some() && services.detected_singbox.is_some() {
|
||||
restart_required.push(ComponentId::Singbox);
|
||||
}
|
||||
let message = if restart_required.is_empty() {
|
||||
helper_result.message.clone()
|
||||
} else {
|
||||
"Конфигурация применена. Для загрузки новых файлов явно перезапустите отмеченные службы."
|
||||
.to_string()
|
||||
};
|
||||
let activity = ActivityEntry {
|
||||
id: "configuration-applied".to_string(),
|
||||
at: services.clock.now(),
|
||||
level: ActivityLevel::Success,
|
||||
title: "Маршрут применён".to_string(),
|
||||
message: format!(
|
||||
"Профилей: {}, приложений: {}. Управление службами не выполнялось.",
|
||||
proxy_config.enabled_profiles, proxy_config.routed_apps
|
||||
),
|
||||
};
|
||||
if let Err(error) = storage.append_activity(activity) {
|
||||
phases.push(phase(
|
||||
"activity",
|
||||
ApplyPhaseStatus::Warning,
|
||||
format!("Маршрут применён, но запись activity не удалась: {error}"),
|
||||
));
|
||||
} else {
|
||||
phases.push(phase(
|
||||
"activity",
|
||||
ApplyPhaseStatus::Succeeded,
|
||||
"Activity обновлена.",
|
||||
));
|
||||
}
|
||||
|
||||
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,
|
||||
message,
|
||||
error_code: None,
|
||||
generated_config_path: proxy_path.display().to_string(),
|
||||
singbox_generated_config_path: singbox_path.map(|path| path.display().to_string()),
|
||||
restart_required,
|
||||
phases,
|
||||
})
|
||||
}
|
||||
|
||||
struct PreparedApply {
|
||||
profiles: Vec<Profile>,
|
||||
targets: Vec<Target>,
|
||||
proxy_config: crate::adapters::proxy_router::ProxyRouterGeneratedConfig,
|
||||
singbox_config: Option<crate::adapters::singbox::SingBoxGeneratedConfig>,
|
||||
}
|
||||
|
||||
fn prepare_apply(
|
||||
storage: &JsonStorage,
|
||||
input: ApplyConfigurationInput,
|
||||
services: &ApplyServices<'_>,
|
||||
) -> Result<PreparedApply, ApplyFlowError> {
|
||||
if services.detected_proxyfier.is_none() {
|
||||
return Err(ApplyFlowError::failure(
|
||||
"proxifyre_not_found",
|
||||
"ProxiFyre не найден. Установите компонент отдельным явным действием перед 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 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(
|
||||
"external_target_missing",
|
||||
"Для external маршрута требуется SOCKS5 target.",
|
||||
)
|
||||
})?;
|
||||
let mut target =
|
||||
normalize_target(target_input).map_err(ApplyFlowError::validation)?;
|
||||
let shared = profiles.iter().any(|existing| {
|
||||
Some(existing.id.as_str()) != profile_input.id.as_deref()
|
||||
&& existing.target_id == target.id
|
||||
});
|
||||
if shared
|
||||
&& targets
|
||||
.iter()
|
||||
.any(|existing| existing.id == target.id && existing != &target)
|
||||
{
|
||||
target.id = format!("target-{}", uuid::Uuid::new_v4());
|
||||
}
|
||||
profile_input.target_id = target.id.clone();
|
||||
upsert_target(&mut targets, target);
|
||||
None
|
||||
}
|
||||
ApplyRouteMode::LocalSingbox => {
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.map_err(|error| storage_error("singbox_config_read_failed", error))?;
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(|error| storage_error("singbox_cache_read_failed", error))?
|
||||
.ok_or_else(|| {
|
||||
ApplyFlowError::failure(
|
||||
"singbox_subscription_cache_missing",
|
||||
"Сначала загрузите подписку Local sing-box.",
|
||||
)
|
||||
})?;
|
||||
profile_input.target_id = LOCAL_SINGBOX_TARGET_ID.to_string();
|
||||
upsert_target(&mut targets, local_singbox_target(&config));
|
||||
Some(
|
||||
services
|
||||
.singbox_adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
services.checker,
|
||||
)
|
||||
.map_err(|error| {
|
||||
ApplyFlowError::failure("singbox_preflight_failed", error.message)
|
||||
})?,
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let profile = normalize_profile(profile_input).map_err(ApplyFlowError::validation)?;
|
||||
if input.disable_other_profiles {
|
||||
for existing in &mut profiles {
|
||||
if existing.id != profile.id {
|
||||
existing.enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
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()),
|
||||
singbox_component_from_detection(services.detected_singbox.as_ref()),
|
||||
];
|
||||
let proxy_config = services
|
||||
.proxy_adapter
|
||||
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||
.map_err(|error| ApplyFlowError::failure("proxifyre_preflight_failed", error.message))?;
|
||||
|
||||
Ok(PreparedApply {
|
||||
profiles,
|
||||
targets,
|
||||
proxy_config,
|
||||
singbox_config,
|
||||
})
|
||||
}
|
||||
|
||||
fn local_singbox_target(config: &LocalSingBoxConfig) -> Target {
|
||||
Target {
|
||||
id: LOCAL_SINGBOX_TARGET_ID.to_string(),
|
||||
name: "Локальный sing-box".to_string(),
|
||||
kind: TargetKind::Local,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host: config.listen_host.clone(),
|
||||
port: config.listen_port,
|
||||
requires_component: Some(ComponentId::Singbox),
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_profile(profiles: &mut Vec<Profile>, profile: Profile) {
|
||||
match profiles
|
||||
.iter()
|
||||
.position(|existing| existing.id == profile.id)
|
||||
{
|
||||
Some(index) => profiles[index] = profile,
|
||||
None => profiles.push(profile),
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_target(targets: &mut Vec<Target>, target: Target) {
|
||||
match targets.iter().position(|existing| existing.id == target.id) {
|
||||
Some(index) => targets[index] = target,
|
||||
None => targets.push(target),
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_result(
|
||||
code: &str,
|
||||
message: String,
|
||||
partial_state: bool,
|
||||
proxy_path: &Path,
|
||||
singbox_path: Option<&Path>,
|
||||
phases: Vec<ApplyPhase>,
|
||||
) -> ApplyConfigurationResult {
|
||||
ApplyConfigurationResult {
|
||||
saved_state: None,
|
||||
success: false,
|
||||
changed: false,
|
||||
partial_state,
|
||||
message,
|
||||
error_code: Some(code.to_string()),
|
||||
generated_config_path: proxy_path.display().to_string(),
|
||||
singbox_generated_config_path: singbox_path.map(|path| path.display().to_string()),
|
||||
restart_required: Vec::new(),
|
||||
phases,
|
||||
}
|
||||
}
|
||||
|
||||
fn phase(
|
||||
id: impl Into<String>,
|
||||
status: ApplyPhaseStatus,
|
||||
message: impl Into<String>,
|
||||
) -> ApplyPhase {
|
||||
ApplyPhase {
|
||||
id: id.into(),
|
||||
status,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_error(code: &str, error: std::io::Error) -> ApplyFlowError {
|
||||
ApplyFlowError::failure(code, format!("Ошибка storage: {error}"))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Small injectable time boundary for deterministic activity records.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub trait Clock {
|
||||
fn now(&self) -> String;
|
||||
}
|
||||
|
||||
pub struct SystemClock;
|
||||
|
||||
impl Clock for SystemClock {
|
||||
fn now(&self) -> String {
|
||||
let seconds = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("unix:{seconds}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
//! Serialized Tauri command boundary types.
|
||||
//!
|
||||
//! System/domain truth stays in `models`; these DTOs only define the stable
|
||||
//! 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,
|
||||
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
|
||||
};
|
||||
use crate::singbox_service::SingBoxSetupStatus;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminStatusResponse {
|
||||
pub is_windows: bool,
|
||||
pub is_elevated: bool,
|
||||
pub can_restart_elevated: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ValidationIssue {
|
||||
pub field: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommandError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub details: Vec<ValidationIssue>,
|
||||
}
|
||||
|
||||
impl CommandError {
|
||||
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
details: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_details(
|
||||
code: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
details: Vec<ValidationIssue>,
|
||||
) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
details,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StatusResponse {
|
||||
pub route_line: String,
|
||||
pub active_profile_count: usize,
|
||||
pub routed_app_count: usize,
|
||||
pub active_target: Option<TargetDto>,
|
||||
pub components: Vec<ComponentStatusDto>,
|
||||
pub recent_activity: Vec<ActivityEntryDto>,
|
||||
pub generated_config_path: String,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[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,
|
||||
pub singbox_status: LocalSingBoxStatusResponse,
|
||||
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 {
|
||||
pub ready: bool,
|
||||
pub missing_count: usize,
|
||||
pub items: Vec<ProxiFyreSetupItemDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxiFyreSetupItemDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub installed: bool,
|
||||
pub version: Option<String>,
|
||||
pub details: 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,
|
||||
pub generated_config_path: String,
|
||||
pub lan_listen_host: Option<String>,
|
||||
#[cfg(debug_assertions)]
|
||||
pub subscription_identity: SubscriptionRequestIdentityDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalSingBoxConfigDto {
|
||||
pub subscription_display_url: Option<String>,
|
||||
pub has_subscription: bool,
|
||||
pub selected_server_tag: Option<String>,
|
||||
pub selected_server_id: Option<String>,
|
||||
pub listen_host: String,
|
||||
pub listen_port: u16,
|
||||
pub service_name: String,
|
||||
pub install_root: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionCacheDto {
|
||||
pub servers: Vec<SubscriptionServerDto>,
|
||||
pub user_info: serde_json::Map<String, serde_json::Value>,
|
||||
pub fetched_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionServerDto {
|
||||
pub id: String,
|
||||
pub tag: String,
|
||||
#[serde(rename = "type")]
|
||||
pub server_type: String,
|
||||
pub server: String,
|
||||
pub server_port: u16,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionRequestIdentityDto {
|
||||
pub headers: Vec<SubscriptionRequestHeaderDto>,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscriptionRequestHeaderDto {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveSingBoxSubscriptionInputDto {
|
||||
pub subscription_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SelectSingBoxServerInputDto {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
pub tag: String,
|
||||
#[serde(default)]
|
||||
pub server: Option<String>,
|
||||
#[serde(default)]
|
||||
pub server_port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PingSingBoxServerInputDto {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PingProxyTargetInputDto {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PingServerResponse {
|
||||
pub id: String,
|
||||
pub tag: String,
|
||||
pub server: String,
|
||||
pub server_port: u16,
|
||||
pub ok: bool,
|
||||
pub latency: Option<u128>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyProbeResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub ok: bool,
|
||||
pub status: Option<u16>,
|
||||
pub latency: Option<u128>,
|
||||
pub ip: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyTargetCheckResponse {
|
||||
pub tag: String,
|
||||
pub server: String,
|
||||
pub server_port: u16,
|
||||
pub ok: bool,
|
||||
pub latency: Option<u128>,
|
||||
pub error: Option<String>,
|
||||
pub probes: Vec<ProxyProbeResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenerateSingBoxConfigResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub adapter_id: String,
|
||||
pub generated_config_path: String,
|
||||
pub selected_server_tag: String,
|
||||
pub listen_host: String,
|
||||
pub listen_port: u16,
|
||||
pub check: Option<SingBoxCheckResult>,
|
||||
pub activity: ActivityEntryDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileInputDto {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub target_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub protocols: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub items: Option<Vec<ProfileItemInputDto>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProfileItemInputDto {
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: String,
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub recursive: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInputDto {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub protocol: Option<String>,
|
||||
pub host: String,
|
||||
pub port: u32,
|
||||
#[serde(default)]
|
||||
pub requires_component: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
pub target_id: String,
|
||||
pub protocols: Vec<Protocol>,
|
||||
pub items: Vec<ProfileItemDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProfileItemDto {
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: ProfileItemType,
|
||||
pub value: String,
|
||||
pub recursive: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: TargetKind,
|
||||
pub protocol: ProxyProtocol,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub requires_component: Option<ComponentId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentStatusDto {
|
||||
pub id: ComponentId,
|
||||
pub name: String,
|
||||
pub state: ComponentState,
|
||||
pub installed: bool,
|
||||
pub running: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_status: Option<String>,
|
||||
pub problems: Vec<String>,
|
||||
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 {
|
||||
pub id: String,
|
||||
pub at: String,
|
||||
pub level: ActivityLevel,
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolveProfilePreviewResponse {
|
||||
pub profile_id: String,
|
||||
pub apps: Vec<ResolvedAppDto>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolvedAppDto {
|
||||
pub source_type: ProfileItemType,
|
||||
pub source_value: String,
|
||||
pub app_name: String,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<ProfileInputDto> for ProfileInput {
|
||||
fn from(input: ProfileInputDto) -> Self {
|
||||
Self {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
enabled: input.enabled.unwrap_or(true),
|
||||
target_id: input
|
||||
.target_id
|
||||
.unwrap_or_else(|| "local-singbox".to_string()),
|
||||
protocols: input
|
||||
.protocols
|
||||
.unwrap_or_else(|| vec!["TCP".to_string(), "UDP".to_string()]),
|
||||
items: input
|
||||
.items
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(ProfileItemInput::from)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProfileItemInputDto> for ProfileItemInput {
|
||||
fn from(input: ProfileItemInputDto) -> Self {
|
||||
Self {
|
||||
item_type: input.item_type,
|
||||
value: input.value,
|
||||
recursive: input.recursive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TargetInputDto> for TargetInput {
|
||||
fn from(input: TargetInputDto) -> Self {
|
||||
Self {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
kind: input.kind.unwrap_or_else(|| "external".to_string()),
|
||||
protocol: input.protocol.unwrap_or_else(|| "socks5".to_string()),
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
requires_component: input.requires_component,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Profile> for ProfileDto {
|
||||
fn from(profile: &Profile) -> Self {
|
||||
Self {
|
||||
id: profile.id.clone(),
|
||||
name: profile.name.clone(),
|
||||
enabled: profile.enabled,
|
||||
target_id: profile.target_id.clone(),
|
||||
protocols: profile.protocols.clone(),
|
||||
items: profile.items.iter().map(ProfileItemDto::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ProfileItem> for ProfileItemDto {
|
||||
fn from(item: &ProfileItem) -> Self {
|
||||
Self {
|
||||
item_type: item.item_type.clone(),
|
||||
value: item.value.clone(),
|
||||
recursive: item.recursive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Target> for TargetDto {
|
||||
fn from(target: &Target) -> Self {
|
||||
Self {
|
||||
id: target.id.clone(),
|
||||
name: target.name.clone(),
|
||||
kind: target.kind.clone(),
|
||||
protocol: target.protocol.clone(),
|
||||
host: target.host.clone(),
|
||||
port: target.port,
|
||||
requires_component: target.requires_component.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ComponentStatus> for ComponentStatusDto {
|
||||
fn from(component: &ComponentStatus) -> Self {
|
||||
Self {
|
||||
id: component.id.clone(),
|
||||
name: component.name.clone(),
|
||||
state: component.state.clone(),
|
||||
installed: component.installed,
|
||||
running: component.running,
|
||||
version: component.version.clone(),
|
||||
path: component.path.clone(),
|
||||
service_name: component.service_name.clone(),
|
||||
service_status: component.service_status.clone(),
|
||||
problems: component.problems.clone(),
|
||||
actions: component.actions.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ActivityEntry> for ActivityEntryDto {
|
||||
fn from(entry: &ActivityEntry) -> Self {
|
||||
Self {
|
||||
id: entry.id.clone(),
|
||||
at: entry.at.clone(),
|
||||
level: entry.level.clone(),
|
||||
title: entry.title.clone(),
|
||||
message: entry.message.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&LocalSingBoxConfig> for LocalSingBoxConfigDto {
|
||||
fn from(config: &LocalSingBoxConfig) -> Self {
|
||||
Self {
|
||||
subscription_display_url: config.subscription_display_url(),
|
||||
has_subscription: config
|
||||
.subscription_url
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty()),
|
||||
selected_server_tag: config.selected_server_tag.clone(),
|
||||
selected_server_id: config.selected_server_id.clone(),
|
||||
listen_host: config.listen_host.clone(),
|
||||
listen_port: config.listen_port,
|
||||
service_name: config.service_name.clone(),
|
||||
install_root: config.install_root.clone(),
|
||||
updated_at: config.updated_at.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SubscriptionCache> for SubscriptionCacheDto {
|
||||
fn from(cache: &SubscriptionCache) -> Self {
|
||||
Self {
|
||||
servers: cache
|
||||
.servers
|
||||
.iter()
|
||||
.map(SubscriptionServerDto::from)
|
||||
.collect(),
|
||||
user_info: cache.user_info.clone(),
|
||||
fetched_at: cache.fetched_at.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SubscriptionServer> for SubscriptionServerDto {
|
||||
fn from(server: &SubscriptionServer) -> Self {
|
||||
Self {
|
||||
id: server.id.clone(),
|
||||
tag: server.tag.clone(),
|
||||
server_type: server.server_type.clone(),
|
||||
server: server.server.clone(),
|
||||
server_port: server.server_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
+1587
-4010
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
+1378
-193
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
@@ -0,0 +1,153 @@
|
||||
//! Live component status resolution and read-only route/profile presentation.
|
||||
|
||||
use crate::command_dto::ResolvedAppDto;
|
||||
use crate::component_detection::{
|
||||
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,
|
||||
};
|
||||
pub(crate) fn live_components() -> Vec<ComponentStatus> {
|
||||
resolve_component_statuses_with_inventories(&inventory_proxyfier(), &inventory_singbox())
|
||||
}
|
||||
|
||||
pub(crate) fn components_with_detection(
|
||||
detected_proxyfier: Option<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Vec<ComponentStatus> {
|
||||
resolve_component_statuses(detected_proxyfier, detected_singbox)
|
||||
}
|
||||
|
||||
pub fn resolve_component_statuses(
|
||||
detected_proxyfier: Option<DetectedProxyfier>,
|
||||
detected_singbox: Option<DetectedSingBox>,
|
||||
) -> Vec<ComponentStatus> {
|
||||
let mut components = default_components();
|
||||
|
||||
upsert_component(
|
||||
&mut components,
|
||||
proxyfier_component_from_detection(detected_proxyfier.as_ref()),
|
||||
);
|
||||
upsert_component(
|
||||
&mut components,
|
||||
singbox_component_from_detection(detected_singbox.as_ref()),
|
||||
);
|
||||
|
||||
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 {
|
||||
id: ComponentId::ControlApp,
|
||||
name: "Приложение управления".to_string(),
|
||||
state: ComponentState::Running,
|
||||
installed: true,
|
||||
running: true,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: None,
|
||||
service_status: None,
|
||||
problems: Vec::new(),
|
||||
actions: vec![
|
||||
"Открыть журнал".to_string(),
|
||||
"Скопировать диагностику".to_string(),
|
||||
],
|
||||
},
|
||||
ComponentStatus {
|
||||
id: ComponentId::Proxyfier,
|
||||
name: "ProxiFyre".to_string(),
|
||||
state: ComponentState::Missing,
|
||||
installed: false,
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: None,
|
||||
problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()],
|
||||
actions: vec!["Установить ProxiFyre".to_string()],
|
||||
},
|
||||
ComponentStatus {
|
||||
id: ComponentId::Singbox,
|
||||
name: "Локальный sing-box".to_string(),
|
||||
state: ComponentState::Missing,
|
||||
installed: false,
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: Some(crate::models::DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string()),
|
||||
service_status: None,
|
||||
problems: Vec::new(),
|
||||
actions: vec!["Установить локальный sing-box".to_string()],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn upsert_component(components: &mut Vec<ComponentStatus>, component: ComponentStatus) {
|
||||
match components
|
||||
.iter()
|
||||
.position(|existing| existing.id == component.id)
|
||||
{
|
||||
Some(index) => components[index] = component,
|
||||
None => components.push(component),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn route_line(active_target: Option<&Target>) -> String {
|
||||
match active_target {
|
||||
Some(target) if target.id == "local-singbox" => {
|
||||
format!(
|
||||
"Выбранные приложения -> ProxiFyre -> локальный sing-box {}:{} -> VPN",
|
||||
target.host, target.port
|
||||
)
|
||||
}
|
||||
Some(target) => format!(
|
||||
"Выбранные приложения -> ProxiFyre -> внешний прокси {}:{}",
|
||||
target.host, target.port
|
||||
),
|
||||
None => "Выбранные приложения -> ProxiFyre -> внешний прокси".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolved_app(item: &ProfileItem, warnings: &mut Vec<String>) -> ResolvedAppDto {
|
||||
let mut notes = Vec::new();
|
||||
match item.item_type {
|
||||
ProfileItemType::Process => notes.push("Имя процесса используется напрямую".to_string()),
|
||||
ProfileItemType::Folder => {
|
||||
let note = "Сканирование папок отложено; ProxiFyre получает путь к папке";
|
||||
notes.push(note.to_string());
|
||||
warnings.push(note.to_string());
|
||||
}
|
||||
ProfileItemType::Exe => {
|
||||
notes.push("Путь к EXE сохраняется для сопоставления в ProxiFyre".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
ResolvedAppDto {
|
||||
source_type: item.item_type.clone(),
|
||||
source_value: item.value.clone(),
|
||||
app_name: item.value.clone(),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
use crate::configuration_transaction::{read_guard, ConfigurationTransaction};
|
||||
// Persisted profiles/targets, startup preparation, and preview use cases.
|
||||
|
||||
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, detected_proxyfier_from_inventory,
|
||||
detected_singbox_from_inventory, inventory_proxyfier, inventory_singbox,
|
||||
};
|
||||
use crate::component_inventory::ComponentClassification;
|
||||
use crate::component_status::{
|
||||
live_components, resolve_component_statuses_with_inventories, resolved_app, route_line,
|
||||
};
|
||||
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;
|
||||
|
||||
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 = 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
|
||||
.iter()
|
||||
.filter(|profile| profile.enabled)
|
||||
.map(|profile| profile.items.len())
|
||||
.sum();
|
||||
let active_target = profiles
|
||||
.iter()
|
||||
.find(|profile| profile.enabled)
|
||||
.and_then(|profile| targets.iter().find(|target| target.id == profile.target_id));
|
||||
let route_line = route_line(active_target);
|
||||
|
||||
Ok(StatusResponse {
|
||||
route_line,
|
||||
active_profile_count,
|
||||
routed_app_count,
|
||||
active_target: active_target.map(TargetDto::from),
|
||||
components: components.iter().map(ComponentStatusDto::from).collect(),
|
||||
recent_activity: activity
|
||||
.iter()
|
||||
.take(10)
|
||||
.map(ActivityEntryDto::from)
|
||||
.collect(),
|
||||
generated_config_path: storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join("proxifyre-app-config.json")
|
||||
.display()
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
.map(|profiles| profiles.iter().map(ProfileDto::from).collect())
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
match profiles
|
||||
.iter()
|
||||
.position(|existing| existing.id == profile.id)
|
||||
{
|
||||
Some(index) => profiles[index] = profile.clone(),
|
||||
None => profiles.push(profile.clone()),
|
||||
}
|
||||
|
||||
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)
|
||||
.map(|targets| targets.iter().map(TargetDto::from).collect())
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
match targets.iter().position(|existing| existing.id == target.id) {
|
||||
Some(index) => targets[index] = target.clone(),
|
||||
None => targets.push(target.clone()),
|
||||
}
|
||||
|
||||
storage.write_targets(&targets).map_err(storage_error)?;
|
||||
transaction.commit().map_err(storage_error)?;
|
||||
Ok(TargetDto::from(&target))
|
||||
}
|
||||
|
||||
pub fn read_live_components() -> Vec<ComponentStatusDto> {
|
||||
live_components()
|
||||
.iter()
|
||||
.map(ComponentStatusDto::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rebuilds only an existing, untrusted derived config from authoritative
|
||||
/// profiles/targets. A missing config still requires an explicit Apply action.
|
||||
pub fn ensure_proxifyre_generated_config_ready(storage: &JsonStorage) -> Result<(), CommandError> {
|
||||
let guard = read_guard(storage).map_err(storage_error)?;
|
||||
let path = storage.paths().generated_dir.join(PROXIFYRE_OUTPUT_FILE);
|
||||
if !path.try_exists().map_err(storage_error)? {
|
||||
return Err(CommandError::new(
|
||||
"generated_config_missing",
|
||||
"Сначала нажмите «Применить», чтобы создать конфигурацию ProxiFyre.",
|
||||
));
|
||||
}
|
||||
let profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
if !profiles
|
||||
.iter()
|
||||
.any(|profile| profile.enabled && !profile.items.is_empty())
|
||||
{
|
||||
return Err(CommandError::new(
|
||||
"route_has_no_apps",
|
||||
"Нет включённых правил. Добавьте приложения и примените конфигурацию перед запуском ProxiFyre.",
|
||||
));
|
||||
}
|
||||
if safe_fs::open_restricted_file_read_lease(&path).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let targets = storage.read_targets().map_err(storage_error)?;
|
||||
let components = live_components();
|
||||
let generated = ProxiFyreAdapter::default()
|
||||
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||
.map_err(|_| {
|
||||
CommandError::new(
|
||||
"generated_config_rebuild_failed",
|
||||
"Старую конфигурацию ProxiFyre нельзя использовать. Нажмите «Применить», чтобы пересоздать её.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let revision =
|
||||
crate::configuration_transaction::revision_locked(storage).map_err(storage_error)?;
|
||||
drop(guard);
|
||||
let transaction =
|
||||
ConfigurationTransaction::begin(storage, Some(&revision)).map_err(storage_error)?;
|
||||
remove_untrusted_generated_file(&path, true)?;
|
||||
remove_untrusted_generated_file(&safe_fs::backup_path(&path), false)?;
|
||||
safe_fs::write_restricted_atomic(&path, generated.contents.as_bytes())
|
||||
.map_err(|_| generated_config_rebuild_error())?;
|
||||
transaction.commit().map_err(storage_error)
|
||||
}
|
||||
|
||||
fn remove_untrusted_generated_file(
|
||||
path: &std::path::Path,
|
||||
required: bool,
|
||||
) -> Result<(), CommandError> {
|
||||
safe_fs::ensure_no_reparse_ancestors(path).map_err(|_| generated_config_rebuild_error())?;
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_file() => {
|
||||
fs::remove_file(path).map_err(|_| generated_config_rebuild_error())
|
||||
}
|
||||
Ok(_) => Err(generated_config_rebuild_error()),
|
||||
Err(error) if !required && error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(_) => Err(generated_config_rebuild_error()),
|
||||
}
|
||||
}
|
||||
|
||||
fn generated_config_rebuild_error() -> CommandError {
|
||||
CommandError::new(
|
||||
"generated_config_rebuild_failed",
|
||||
"Не удалось безопасно пересоздать старую конфигурацию ProxiFyre. Нажмите «Применить» и повторите запуск.",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn read_startup_snapshot(
|
||||
storage: &JsonStorage,
|
||||
startup_session_id: &str,
|
||||
) -> Result<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_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();
|
||||
let proxifyre_setup_status = build_proxifyre_setup_status_with_detection(
|
||||
detected_proxyfier.as_ref(),
|
||||
&default_proxifyre_install_dir(),
|
||||
);
|
||||
let singbox_status = read_singbox_status_with_detection(storage, detected_singbox.as_ref())?;
|
||||
let saved_state = singbox_status.saved_state.clone();
|
||||
let singbox_setup_status = build_singbox_setup_status_with_install_root(
|
||||
detected_singbox.as_ref(),
|
||||
&default_singbox_install_dir(),
|
||||
);
|
||||
|
||||
Ok(StartupSnapshotResponse {
|
||||
admin_status: admin_status(),
|
||||
migration_status,
|
||||
saved_state,
|
||||
components,
|
||||
proxifyre_setup_status,
|
||||
singbox_status,
|
||||
singbox_setup_status,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_activity(storage: &JsonStorage) -> Result<Vec<ActivityEntryDto>, CommandError> {
|
||||
storage
|
||||
.read_activity()
|
||||
.map_err(storage_error)
|
||||
.map(|entries| entries.iter().map(ActivityEntryDto::from).collect())
|
||||
}
|
||||
|
||||
pub fn read_saved_state(storage: &JsonStorage) -> Result<SavedStateResponse, CommandError> {
|
||||
let _guard = read_guard(storage).map_err(storage_error)?;
|
||||
read_saved_state_locked(storage)
|
||||
}
|
||||
|
||||
pub(crate) fn read_saved_state_locked(
|
||||
storage: &JsonStorage,
|
||||
) -> Result<SavedStateResponse, CommandError> {
|
||||
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
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join("proxifyre-app-config.json")
|
||||
.display()
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_preview(
|
||||
input: ProfileInputDto,
|
||||
) -> Result<ResolveProfilePreviewResponse, CommandError> {
|
||||
let profile = normalize_profile(input.into()).map_err(validation_error)?;
|
||||
let mut warnings = Vec::new();
|
||||
let apps = profile
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| resolved_app(item, &mut warnings))
|
||||
.collect();
|
||||
|
||||
Ok(ResolveProfilePreviewResponse {
|
||||
profile_id: profile.id,
|
||||
apps,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn storage_error(error: std::io::Error) -> CommandError {
|
||||
CommandError::new("storage_error", error.to_string())
|
||||
}
|
||||
|
||||
fn validation_error(errors: Vec<ValidationError>) -> CommandError {
|
||||
CommandError::with_details(
|
||||
"validation_error",
|
||||
"Проверка введенных данных не прошла",
|
||||
errors
|
||||
.into_iter()
|
||||
.map(|error| ValidationIssue {
|
||||
field: error.field,
|
||||
message: error.message,
|
||||
})
|
||||
.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
|
||||
)
|
||||
}
|
||||
+140
-13
@@ -1,16 +1,144 @@
|
||||
pub mod activity;
|
||||
pub mod admin;
|
||||
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 elevated_scripts;
|
||||
pub mod helper;
|
||||
pub mod component_inventory;
|
||||
pub mod component_packages;
|
||||
pub mod component_status;
|
||||
pub mod configuration_transaction;
|
||||
pub mod configuration_use_case;
|
||||
pub mod migration;
|
||||
pub mod models;
|
||||
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 proxy_apply;
|
||||
pub mod proxy_probe;
|
||||
pub mod route_state;
|
||||
pub mod safe_fs;
|
||||
pub mod singbox_config;
|
||||
pub mod singbox_runtime;
|
||||
pub mod singbox_service;
|
||||
pub mod singbox_subscription;
|
||||
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;
|
||||
@@ -22,21 +150,21 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(commands::CommandState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
commands::get_admin_status,
|
||||
commands::restart_as_admin,
|
||||
commands::get_startup_snapshot,
|
||||
commands::get_profiles,
|
||||
commands::get_saved_state,
|
||||
commands::save_profile,
|
||||
commands::get_targets,
|
||||
commands::save_target,
|
||||
commands::get_components,
|
||||
commands::get_proxifyre_setup_status,
|
||||
commands::get_singbox_status,
|
||||
commands::get_singbox_setup_status,
|
||||
commands::resolve_profile_preview,
|
||||
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,
|
||||
@@ -44,12 +172,11 @@ pub fn run() {
|
||||
commands::ping_all_singbox_servers,
|
||||
commands::ping_proxy_target,
|
||||
commands::generate_singbox_config,
|
||||
commands::apply_profiles,
|
||||
commands::get_logs,
|
||||
commands::open_config_location,
|
||||
commands::apply_configuration,
|
||||
commands::start_proxifyre_service,
|
||||
commands::stop_proxifyre_service,
|
||||
commands::install_proxifyre,
|
||||
commands::configure_proxifyre_firewall_rules,
|
||||
commands::uninstall_proxifyre,
|
||||
commands::start_singbox_service,
|
||||
commands::stop_singbox_service,
|
||||
|
||||
@@ -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
+69
-24
@@ -1,12 +1,12 @@
|
||||
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";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
|
||||
pub const DEFAULT_LOCAL_SINGBOX_SERVICE_NAME: &str = "ProxyWardenSingBox";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str = r"C:\Program Files\ProxyWarden\sing-box";
|
||||
pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str =
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
@@ -56,6 +56,7 @@ pub enum ComponentState {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileItemInput {
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: String,
|
||||
@@ -65,6 +66,7 @@ pub struct ProfileItemInput {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileInput {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
@@ -97,6 +99,7 @@ pub struct Profile {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInput {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
@@ -131,6 +134,10 @@ pub struct ComponentStatus {
|
||||
pub version: Option<String>,
|
||||
pub path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub service_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub service_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub problems: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub actions: Vec<String>,
|
||||
@@ -144,18 +151,44 @@ pub struct LocalSingBoxConfig {
|
||||
pub device_hwid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_server_id: Option<String>,
|
||||
#[serde(default = "default_local_singbox_listen_host")]
|
||||
pub listen_host: String,
|
||||
#[serde(default = "default_local_singbox_listen_port")]
|
||||
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
|
||||
@@ -176,6 +209,7 @@ impl Default for LocalSingBoxConfig {
|
||||
subscription_url: None,
|
||||
device_hwid: None,
|
||||
selected_server_tag: None,
|
||||
selected_server_id: None,
|
||||
listen_host: default_local_singbox_listen_host(),
|
||||
listen_port: default_local_singbox_listen_port(),
|
||||
service_name: default_local_singbox_service_name(),
|
||||
@@ -199,34 +233,17 @@ impl SubscriptionCache {
|
||||
pub fn normalize_percent_encoded_tags(&mut self) {
|
||||
for server in &mut self.servers {
|
||||
server.tag = decode_percent_encoded_utf8(&server.tag);
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SubscriptionServer {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
pub tag: String,
|
||||
#[serde(rename = "type")]
|
||||
pub server_type: String,
|
||||
@@ -234,6 +251,34 @@ pub struct SubscriptionServer {
|
||||
pub server_port: u16,
|
||||
}
|
||||
|
||||
impl SubscriptionServer {
|
||||
pub fn ensure_id(&mut self) {
|
||||
if self.id.trim().is_empty() {
|
||||
self.id = subscription_server_id(
|
||||
&self.server_type,
|
||||
&self.tag,
|
||||
&self.server,
|
||||
self.server_port,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscription_server_id(
|
||||
server_type: &str,
|
||||
tag: &str,
|
||||
server: &str,
|
||||
server_port: u16,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}|{}|{}|{}",
|
||||
server_type.trim().to_ascii_lowercase(),
|
||||
tag.trim(),
|
||||
server.trim().to_ascii_lowercase(),
|
||||
server_port
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ActivityEntry {
|
||||
pub id: String,
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
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
@@ -0,0 +1,142 @@
|
||||
//! Ownership proof for destructive ProxiFyre uninstall operations.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub const PROXIFYRE_MARKER_FILE: &str = "proxywarden-component.json";
|
||||
pub const PROXIFYRE_MANAGED_SERVICE_NAME: &str = "ProxiFyreService";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManagedProxiFyreOwnership {
|
||||
pub service_name: String,
|
||||
pub remove_packet_filter: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProxiFyreInstallMarker {
|
||||
manager: String,
|
||||
component: String,
|
||||
service_name: String,
|
||||
install_root: String,
|
||||
#[serde(default)]
|
||||
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,
|
||||
expected_install_dir: &Path,
|
||||
) -> Result<ManagedProxiFyreOwnership, String> {
|
||||
let install_dir = canonical_path(install_dir, "папку ProxiFyre")?;
|
||||
let expected_install_dir = canonical_path(expected_install_dir, "ожидаемую папку ProxiFyre")?;
|
||||
if install_dir != expected_install_dir {
|
||||
return Err(format!(
|
||||
"папка {} не является управляемой папкой {}",
|
||||
install_dir.display(),
|
||||
expected_install_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
let has_expected_shape = install_dir
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("ProxiFyre"))
|
||||
&& install_dir
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("components"));
|
||||
if !has_expected_shape {
|
||||
return Err("управляемая папка должна оканчиваться на components\\ProxiFyre".to_string());
|
||||
}
|
||||
|
||||
let executable_path = canonical_path(executable_path, "ProxiFyre.exe")?;
|
||||
if executable_path.parent() != Some(install_dir.as_path())
|
||||
|| !executable_path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("ProxiFyre.exe"))
|
||||
{
|
||||
return Err("обнаруженный ProxiFyre.exe находится вне управляемой папки".to_string());
|
||||
}
|
||||
|
||||
let marker_path = install_dir.join(PROXIFYRE_MARKER_FILE);
|
||||
let marker_text = fs::read_to_string(&marker_path).map_err(|error| {
|
||||
format!(
|
||||
"не удалось прочитать marker установки {}: {error}",
|
||||
marker_path.display()
|
||||
)
|
||||
})?;
|
||||
let marker = parse_marker(&marker_text).map_err(|error| {
|
||||
format!(
|
||||
"marker установки {} содержит некорректные данные: {error}",
|
||||
marker_path.display()
|
||||
)
|
||||
})?;
|
||||
validate_marker_identity(&marker)?;
|
||||
|
||||
let marker_root = canonical_path(Path::new(&marker.install_root), "installRoot из marker")?;
|
||||
if marker_root != 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,
|
||||
})
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
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
@@ -0,0 +1,294 @@
|
||||
//! ProxiFyre config apply helper boundary and testable legacy apply fixture.
|
||||
//!
|
||||
//! The current webview path uses `apply_flow`; the lower-level fixture remains
|
||||
//! for adapter/storage integration tests and shares the same detected writer.
|
||||
|
||||
use crate::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
ProxyRouterRequest,
|
||||
};
|
||||
use crate::clock::Clock;
|
||||
use crate::command_dto::{ActivityEntryDto, CommandError};
|
||||
use crate::component_detection::{
|
||||
detect_proxyfier_install, detect_singbox_install, inventory_proxyfier_with_host,
|
||||
inventory_proxyfier_with_host_and_current_root, DetectedProxyfier, DetectedSingBox,
|
||||
ProxyfierDetectionHost, SystemProxyfierDetectionHost,
|
||||
};
|
||||
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;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyProfilesResponse {
|
||||
pub success: bool,
|
||||
pub changed: bool,
|
||||
pub message: String,
|
||||
pub adapter_id: String,
|
||||
pub generated_config_path: String,
|
||||
pub enabled_profiles: usize,
|
||||
pub routed_apps: usize,
|
||||
pub helper: HelperApplyResult,
|
||||
pub activity: ActivityEntryDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HelperApplyResult {
|
||||
pub success: bool,
|
||||
pub changed: bool,
|
||||
pub action: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub struct HelperApplyRequest<'a> {
|
||||
pub adapter_id: &'a str,
|
||||
pub config_path: &'a Path,
|
||||
pub config_contents: &'a str,
|
||||
}
|
||||
|
||||
pub trait ProxyApplyHelper {
|
||||
fn apply_proxy_config(
|
||||
&self,
|
||||
request: HelperApplyRequest<'_>,
|
||||
) -> Result<HelperApplyResult, CommandError>;
|
||||
}
|
||||
|
||||
pub struct DetectedProxyApplyHelper<H = SystemProxyfierDetectionHost> {
|
||||
host: H,
|
||||
current_root: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
|
||||
pub fn system() -> Self {
|
||||
SystemProxyfierDetectionHost.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl<H> From<H> for DetectedProxyApplyHelper<H> {
|
||||
fn from(host: H) -> Self {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<H> ProxyApplyHelper for DetectedProxyApplyHelper<H>
|
||||
where
|
||||
H: ProxyfierDetectionHost,
|
||||
{
|
||||
fn apply_proxy_config(
|
||||
&self,
|
||||
request: HelperApplyRequest<'_>,
|
||||
) -> Result<HelperApplyResult, CommandError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fn authorized_action_error(error: AuthorizedActionError<CommandError>) -> CommandError {
|
||||
match error {
|
||||
AuthorizedActionError::Denied(issue) => CommandError::new(issue.code, issue.message),
|
||||
AuthorizedActionError::Runner(error) => error,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_profiles_with_services(
|
||||
storage: &JsonStorage,
|
||||
adapter: &impl ProxyRouterAdapter,
|
||||
helper: &impl ProxyApplyHelper,
|
||||
clock: &impl Clock,
|
||||
) -> Result<ApplyProfilesResponse, CommandError> {
|
||||
apply_profiles_with_services_and_detection(
|
||||
storage,
|
||||
adapter,
|
||||
helper,
|
||||
clock,
|
||||
detect_proxyfier_install(),
|
||||
detect_singbox_install(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn apply_profiles_with_services_and_detection(
|
||||
storage: &JsonStorage,
|
||||
adapter: &impl ProxyRouterAdapter,
|
||||
helper: &impl ProxyApplyHelper,
|
||||
clock: &impl Clock,
|
||||
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_with_detection(detected_proxyfier, detected_singbox);
|
||||
let generated =
|
||||
match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) {
|
||||
Ok(generated) => generated,
|
||||
Err(error) => {
|
||||
let command_error = adapter_error(error);
|
||||
let activity = activity_for_apply_error(clock, &command_error);
|
||||
storage.append_activity(activity).map_err(storage_error)?;
|
||||
return Err(command_error);
|
||||
}
|
||||
};
|
||||
|
||||
let generated_path = storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join(generated.output_file_name.as_str());
|
||||
write_generated_config(&generated_path, &generated.contents)?;
|
||||
|
||||
let helper_result = helper.apply_proxy_config(HelperApplyRequest {
|
||||
adapter_id: generated.adapter_id.as_str(),
|
||||
config_path: &generated_path,
|
||||
config_contents: generated.contents.as_str(),
|
||||
})?;
|
||||
|
||||
crate::route_state::record_prepared_locked(
|
||||
storage,
|
||||
crate::privileged_jobs::ManagedComponent::Proxifyre,
|
||||
)
|
||||
.map_err(storage_error)?;
|
||||
if helper_result.success {
|
||||
transaction.commit().map_err(storage_error)?;
|
||||
} else {
|
||||
drop(transaction);
|
||||
}
|
||||
let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result);
|
||||
let _ = storage.append_activity(activity.clone());
|
||||
|
||||
Ok(ApplyProfilesResponse {
|
||||
success: helper_result.success,
|
||||
changed: helper_result.changed,
|
||||
message: helper_result.message.clone(),
|
||||
adapter_id: generated.adapter_id,
|
||||
generated_config_path: generated_path.display().to_string(),
|
||||
enabled_profiles: generated.enabled_profiles,
|
||||
routed_apps: generated.routed_apps,
|
||||
helper: helper_result,
|
||||
activity: ActivityEntryDto::from(&activity),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
|
||||
safe_fs::write_restricted_with_backup(path, contents.as_bytes()).map_err(storage_error)
|
||||
}
|
||||
|
||||
fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result<HelperApplyResult, CommandError> {
|
||||
Ok(HelperApplyResult {
|
||||
success: true,
|
||||
changed: true,
|
||||
action: format!("{}.stage-generated-config", request.adapter_id),
|
||||
message: format!(
|
||||
"Сгенерированный конфиг подготовлен в {}; совместимая установка ProxiFyre не найдена",
|
||||
request.config_path.display()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
generated_path: &Path,
|
||||
helper_result: &HelperApplyResult,
|
||||
) -> ActivityEntry {
|
||||
let level = if helper_result.success {
|
||||
ActivityLevel::Success
|
||||
} else {
|
||||
ActivityLevel::Error
|
||||
};
|
||||
|
||||
ActivityEntry {
|
||||
id: format!("apply-{}", generated.adapter_id),
|
||||
at: clock.now(),
|
||||
level,
|
||||
title: "Конфиг ProxiFyre создан".to_string(),
|
||||
message: format!(
|
||||
"Профилей: {}, приложений: {}, конфиг: {}",
|
||||
generated.enabled_profiles,
|
||||
generated.routed_apps,
|
||||
generated_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn activity_for_apply_error(clock: &impl Clock, error: &CommandError) -> ActivityEntry {
|
||||
ActivityEntry {
|
||||
id: format!("apply-error-{}", error.code),
|
||||
at: clock.now(),
|
||||
level: ActivityLevel::Error,
|
||||
title: "Применение ProxiFyre заблокировано".to_string(),
|
||||
message: error.message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_error(error: std::io::Error) -> CommandError {
|
||||
CommandError::new("storage_error", error.to_string())
|
||||
}
|
||||
|
||||
fn adapter_error(error: ProxyRouterError) -> CommandError {
|
||||
let code = match error.kind {
|
||||
ProxyRouterErrorKind::EmptyProfileItems => "empty_profile_items",
|
||||
ProxyRouterErrorKind::MissingTarget => "missing_target",
|
||||
ProxyRouterErrorKind::MissingRequiredComponent => "missing_required_component",
|
||||
ProxyRouterErrorKind::RequiredComponentNotRunning => "required_component_not_running",
|
||||
ProxyRouterErrorKind::UnsupportedTargetProtocol => "unsupported_target_protocol",
|
||||
ProxyRouterErrorKind::Serialization => "serialization_error",
|
||||
};
|
||||
|
||||
CommandError::new(code, error.message)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! TCP and outbound HTTP checks used to verify a configured SOCKS5 route.
|
||||
//!
|
||||
//! All functions are blocking. Tauri handlers must call them through
|
||||
//! `spawn_blocking`; probe URLs are static and never come from webview input.
|
||||
|
||||
use crate::command_dto::{
|
||||
CommandError, PingProxyTargetInputDto, PingServerResponse, ProxyProbeResponse,
|
||||
ProxyTargetCheckResponse,
|
||||
};
|
||||
use std::net::{IpAddr, TcpStream, ToSocketAddrs};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const PROXY_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
const PROXY_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const PROXY_CHECK_USER_AGENT: &str = "proxywarden route-check";
|
||||
|
||||
const DEFAULT_PROXY_PROBES: &[ProxyProbeEndpoint] = &[
|
||||
ProxyProbeEndpoint {
|
||||
id: "cloudflare-trace",
|
||||
name: "Cloudflare Trace",
|
||||
url: "https://www.cloudflare.com/cdn-cgi/trace",
|
||||
ip_source: ProbeIpSource::CloudflareTrace,
|
||||
},
|
||||
ProxyProbeEndpoint {
|
||||
id: "cloudflare-speed",
|
||||
name: "Cloudflare Speed",
|
||||
url: "https://speed.cloudflare.com/meta",
|
||||
ip_source: ProbeIpSource::JsonField("clientIp"),
|
||||
},
|
||||
ProxyProbeEndpoint {
|
||||
id: "ipify",
|
||||
name: "ipify",
|
||||
url: "https://api.ipify.org?format=json",
|
||||
ip_source: ProbeIpSource::JsonField("ip"),
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ProxyProbeEndpoint {
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
url: &'static str,
|
||||
ip_source: ProbeIpSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ProbeIpSource {
|
||||
CloudflareTrace,
|
||||
JsonField(&'static str),
|
||||
}
|
||||
|
||||
pub fn ping_proxy_target_endpoint(
|
||||
input: PingProxyTargetInputDto,
|
||||
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||||
ping_proxy_target_endpoint_with_probes(input, DEFAULT_PROXY_PROBES)
|
||||
}
|
||||
|
||||
pub fn ping_proxy_target_endpoint_with_probes(
|
||||
input: PingProxyTargetInputDto,
|
||||
probes: &[ProxyProbeEndpoint],
|
||||
) -> Result<ProxyTargetCheckResponse, CommandError> {
|
||||
let host = input.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(CommandError::new(
|
||||
"proxy_target_host_missing",
|
||||
"Хост внешнего прокси не указан.",
|
||||
));
|
||||
}
|
||||
|
||||
let tcp = ping_endpoint("route-proxy", "route-proxy", host, input.port);
|
||||
if !tcp.ok {
|
||||
return Ok(ProxyTargetCheckResponse {
|
||||
tag: "route-proxy".to_string(),
|
||||
server: host.to_string(),
|
||||
server_port: input.port,
|
||||
ok: false,
|
||||
latency: tcp.latency,
|
||||
error: tcp.error,
|
||||
probes: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let probe_results = run_proxy_probes(host, input.port, probes);
|
||||
let has_probe_success = probe_results.iter().any(|probe| probe.ok);
|
||||
let ok = probe_results.is_empty() || has_probe_success;
|
||||
let error = (!ok).then(|| {
|
||||
"SOCKS5 порт доступен, но тестовые HTTP endpoints не ответили через прокси.".to_string()
|
||||
});
|
||||
|
||||
Ok(ProxyTargetCheckResponse {
|
||||
tag: "route-proxy".to_string(),
|
||||
server: host.to_string(),
|
||||
server_port: input.port,
|
||||
ok,
|
||||
latency: tcp.latency,
|
||||
error,
|
||||
probes: probe_results,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ping_endpoint(id: &str, tag: &str, server: &str, server_port: u16) -> PingServerResponse {
|
||||
let started = Instant::now();
|
||||
let addresses = match (server, server_port).to_socket_addrs() {
|
||||
Ok(addresses) => addresses.collect::<Vec<_>>(),
|
||||
Err(error) => {
|
||||
return PingServerResponse {
|
||||
id: id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
server: server.to_string(),
|
||||
server_port,
|
||||
ok: false,
|
||||
latency: None,
|
||||
error: Some(format!("DNS/адрес недоступен: {error}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if addresses.is_empty() {
|
||||
return PingServerResponse {
|
||||
id: id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
server: server.to_string(),
|
||||
server_port,
|
||||
ok: false,
|
||||
latency: None,
|
||||
error: Some("DNS не вернул адреса".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let timeout = Duration::from_secs(2);
|
||||
let mut last_error = None;
|
||||
for address in addresses {
|
||||
match TcpStream::connect_timeout(&address, timeout) {
|
||||
Ok(_) => {
|
||||
return PingServerResponse {
|
||||
id: id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
server: server.to_string(),
|
||||
server_port,
|
||||
ok: true,
|
||||
latency: Some(started.elapsed().as_millis()),
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
Err(error) => last_error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
PingServerResponse {
|
||||
id: id.to_string(),
|
||||
tag: tag.to_string(),
|
||||
server: server.to_string(),
|
||||
server_port,
|
||||
ok: false,
|
||||
latency: None,
|
||||
error: last_error,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_proxy_probes(
|
||||
proxy_host: &str,
|
||||
proxy_port: u16,
|
||||
probes: &[ProxyProbeEndpoint],
|
||||
) -> Vec<ProxyProbeResponse> {
|
||||
if probes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let proxy_url = socks5h_proxy_url(proxy_host, proxy_port);
|
||||
let client = match reqwest::Proxy::all(&proxy_url).and_then(|proxy| {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(PROXY_CHECK_TIMEOUT)
|
||||
.connect_timeout(PROXY_CHECK_CONNECT_TIMEOUT)
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
}) {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
return probes
|
||||
.iter()
|
||||
.map(|probe| {
|
||||
failed_probe(
|
||||
*probe,
|
||||
format!("Не удалось подготовить SOCKS5 проверку: {error}"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
|
||||
let handles = probes
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|probe| {
|
||||
let client = client.clone();
|
||||
std::thread::spawn(move || run_proxy_probe(&client, probe))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
handles
|
||||
.into_iter()
|
||||
.zip(probes.iter().copied())
|
||||
.map(|(handle, probe)| {
|
||||
handle
|
||||
.join()
|
||||
.unwrap_or_else(|_| failed_probe(probe, "Проверка была прервана.".to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_proxy_probe(
|
||||
client: &reqwest::blocking::Client,
|
||||
probe: ProxyProbeEndpoint,
|
||||
) -> ProxyProbeResponse {
|
||||
let started = Instant::now();
|
||||
let response = match client
|
||||
.get(probe.url)
|
||||
.header(reqwest::header::USER_AGENT, PROXY_CHECK_USER_AGENT)
|
||||
.send()
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => return failed_probe(probe, format!("HTTP через SOCKS5 не прошел: {error}")),
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
let status_code = status.as_u16();
|
||||
let body = match response.text() {
|
||||
Ok(body) => body,
|
||||
Err(error) => {
|
||||
return failed_probe_with_status(
|
||||
probe,
|
||||
status_code,
|
||||
format!("Ответ не прочитан: {error}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
let latency = started.elapsed().as_millis();
|
||||
|
||||
if !status.is_success() {
|
||||
return ProxyProbeResponse {
|
||||
id: probe.id.to_string(),
|
||||
name: probe.name.to_string(),
|
||||
url: probe.url.to_string(),
|
||||
ok: false,
|
||||
status: Some(status_code),
|
||||
latency: Some(latency),
|
||||
ip: None,
|
||||
error: Some(format!("HTTP {status_code}")),
|
||||
};
|
||||
}
|
||||
|
||||
ProxyProbeResponse {
|
||||
id: probe.id.to_string(),
|
||||
name: probe.name.to_string(),
|
||||
url: probe.url.to_string(),
|
||||
ok: true,
|
||||
status: Some(status_code),
|
||||
latency: Some(latency),
|
||||
ip: extract_probe_ip(probe, &body),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_probe(probe: ProxyProbeEndpoint, error: String) -> ProxyProbeResponse {
|
||||
failed_probe_with_status(probe, 0, error)
|
||||
}
|
||||
|
||||
fn failed_probe_with_status(
|
||||
probe: ProxyProbeEndpoint,
|
||||
status: u16,
|
||||
error: String,
|
||||
) -> ProxyProbeResponse {
|
||||
ProxyProbeResponse {
|
||||
id: probe.id.to_string(),
|
||||
name: probe.name.to_string(),
|
||||
url: probe.url.to_string(),
|
||||
ok: false,
|
||||
status: (status > 0).then_some(status),
|
||||
latency: None,
|
||||
ip: None,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn socks5h_proxy_url(host: &str, port: u16) -> String {
|
||||
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
|
||||
if host.contains(':') {
|
||||
format!("socks5h://[{host}]:{port}")
|
||||
} else {
|
||||
format!("socks5h://{host}:{port}")
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_probe_ip(probe: ProxyProbeEndpoint, body: &str) -> Option<String> {
|
||||
match probe.ip_source {
|
||||
ProbeIpSource::CloudflareTrace => body
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("ip=").and_then(normalize_ip)),
|
||||
ProbeIpSource::JsonField(field) => serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get(field)
|
||||
.and_then(|field| field.as_str())
|
||||
.and_then(normalize_ip)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ip(value: &str) -> Option<String> {
|
||||
let candidate = value.trim().trim_matches('"');
|
||||
candidate
|
||||
.parse::<IpAddr>()
|
||||
.is_ok()
|
||||
.then(|| candidate.to_string())
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,142 @@
|
||||
//! Local sing-box config generation and derived local-target persistence.
|
||||
|
||||
use crate::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxConfigChecker, SingBoxConfigError, SingBoxConfigErrorKind,
|
||||
SingBoxGeneratedConfig, SingBoxGenerationRequest,
|
||||
};
|
||||
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::storage::JsonStorage;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn generate_singbox_config_with_services<C>(
|
||||
storage: &JsonStorage,
|
||||
adapter: &SingBoxAdapter,
|
||||
checker: &C,
|
||||
clock: &impl Clock,
|
||||
binary_path: Option<&Path>,
|
||||
) -> Result<GenerateSingBoxConfigResponse, CommandError>
|
||||
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 = 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),
|
||||
checker,
|
||||
)
|
||||
.map_err(singbox_adapter_error)?;
|
||||
let generated_path = storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join(generated.output_file_name.as_str());
|
||||
|
||||
let transaction =
|
||||
ConfigurationTransaction::begin(storage, Some(&revision)).map_err(storage_error)?;
|
||||
write_generated_config(&generated_path, &generated.contents)?;
|
||||
ensure_local_singbox_target(storage, &config)?;
|
||||
|
||||
crate::route_state::record_prepared_locked(
|
||||
storage,
|
||||
crate::privileged_jobs::ManagedComponent::SingBox,
|
||||
)
|
||||
.map_err(storage_error)?;
|
||||
transaction.commit().map_err(storage_error)?;
|
||||
let activity = activity_for_singbox_generate(clock, &generated, &generated_path);
|
||||
let _ = storage.append_activity(activity.clone());
|
||||
|
||||
Ok(GenerateSingBoxConfigResponse {
|
||||
success: true,
|
||||
message: "Конфиг Local sing-box создан".to_string(),
|
||||
adapter_id: generated.adapter_id,
|
||||
generated_config_path: generated_path.display().to_string(),
|
||||
selected_server_tag: generated.selected_server_tag,
|
||||
listen_host: generated.listen,
|
||||
listen_port: generated.listen_port,
|
||||
check: generated.check,
|
||||
activity: ActivityEntryDto::from(&activity),
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_local_singbox_target(
|
||||
storage: &JsonStorage,
|
||||
config: &LocalSingBoxConfig,
|
||||
) -> Result<(), CommandError> {
|
||||
let mut targets = storage.read_targets().map_err(storage_error)?;
|
||||
let target = Target {
|
||||
id: "local-singbox".to_string(),
|
||||
name: "Локальный sing-box".to_string(),
|
||||
kind: TargetKind::Local,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host: config.listen_host.clone(),
|
||||
port: config.listen_port,
|
||||
requires_component: Some(ComponentId::Singbox),
|
||||
};
|
||||
|
||||
match targets.iter().position(|existing| existing.id == target.id) {
|
||||
Some(index) => targets[index] = target,
|
||||
None => targets.push(target),
|
||||
}
|
||||
|
||||
storage.write_targets(&targets).map_err(storage_error)
|
||||
}
|
||||
|
||||
fn activity_for_singbox_generate(
|
||||
clock: &impl Clock,
|
||||
generated: &SingBoxGeneratedConfig,
|
||||
generated_path: &Path,
|
||||
) -> ActivityEntry {
|
||||
ActivityEntry {
|
||||
id: "singbox-config-generated".to_string(),
|
||||
at: clock.now(),
|
||||
level: ActivityLevel::Success,
|
||||
title: "Конфиг Local sing-box создан".to_string(),
|
||||
message: format!(
|
||||
"Сервер: {}, listen: {}:{}, конфиг: {}",
|
||||
generated.selected_server_tag,
|
||||
generated.listen,
|
||||
generated.listen_port,
|
||||
generated_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn singbox_adapter_error(error: SingBoxConfigError) -> CommandError {
|
||||
let code = match error.kind {
|
||||
SingBoxConfigErrorKind::MissingSelectedServer => "singbox_server_not_selected",
|
||||
SingBoxConfigErrorKind::MissingSelectedOutbound => "singbox_selected_server_missing",
|
||||
SingBoxConfigErrorKind::UnsupportedSelectedOutbound => {
|
||||
"singbox_selected_server_unsupported"
|
||||
}
|
||||
SingBoxConfigErrorKind::Serialization => "serialization_error",
|
||||
SingBoxConfigErrorKind::CheckFailed => "singbox_check_failed",
|
||||
};
|
||||
|
||||
CommandError::new(code, error.message)
|
||||
}
|
||||
|
||||
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
|
||||
safe_fs::write_restricted_with_backup(path, contents.as_bytes()).map_err(storage_error)
|
||||
}
|
||||
|
||||
fn storage_error(error: std::io::Error) -> CommandError {
|
||||
CommandError::new("storage_error", error.to_string())
|
||||
}
|
||||
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
+116
-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;
|
||||
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,26 +134,26 @@ 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,
|
||||
&PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBoxSetupStatus {
|
||||
pub fn build_singbox_setup_status_with_install_root(
|
||||
detected: Option<&DetectedSingBox>,
|
||||
default_install_root: &Path,
|
||||
) -> SingBoxSetupStatus {
|
||||
let install_root = detected
|
||||
.map(|singbox| singbox.install_dir.display().to_string())
|
||||
.unwrap_or_else(|| DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string());
|
||||
.unwrap_or_else(|| default_install_root.display().to_string());
|
||||
let binary_item = match detected {
|
||||
Some(singbox) if singbox.binary_exists => SingBoxSetupItem {
|
||||
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 {
|
||||
@@ -82,7 +171,7 @@ pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBox
|
||||
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 {
|
||||
@@ -100,14 +189,14 @@ pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBox
|
||||
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 {
|
||||
@@ -128,142 +217,3 @@ pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBox
|
||||
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();
|
||||
|
||||
if file_name == "sing-box"
|
||||
&& (normalized.contains("\\proxywarden\\") || normalized.contains("\\proxywarden\\"))
|
||||
{
|
||||
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('\'', "''")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Local sing-box subscription persistence, selection, status, and ping use cases.
|
||||
|
||||
use crate::clock::Clock;
|
||||
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,
|
||||
};
|
||||
use crate::proxy_probe::ping_endpoint;
|
||||
use crate::storage::JsonStorage;
|
||||
use crate::subscription;
|
||||
use std::net::{IpAddr, UdpSocket};
|
||||
|
||||
pub trait SubscriptionFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError>;
|
||||
}
|
||||
|
||||
pub struct SystemSubscriptionFetcher;
|
||||
|
||||
impl SubscriptionFetcher for SystemSubscriptionFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
identity: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
subscription::fetch_subscription_with_identity(url, identity)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn subscription_request_identity_for_display() -> SubscriptionRequestIdentityDto {
|
||||
let identity = subscription::SubscriptionFetchIdentity::default();
|
||||
let headers = identity
|
||||
.request_headers_without_device_hwid()
|
||||
.into_iter()
|
||||
.map(|(name, value)| SubscriptionRequestHeaderDto {
|
||||
name: name.to_string(),
|
||||
value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
SubscriptionRequestIdentityDto { headers }
|
||||
}
|
||||
|
||||
pub fn read_singbox_status(
|
||||
storage: &JsonStorage,
|
||||
) -> Result<LocalSingBoxStatusResponse, CommandError> {
|
||||
let detected = detect_singbox_install();
|
||||
read_singbox_status_with_detection(storage, detected.as_ref())
|
||||
}
|
||||
|
||||
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 {
|
||||
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()
|
||||
.generated_dir
|
||||
.join("sing-box-config.json")
|
||||
.display()
|
||||
.to_string(),
|
||||
lan_listen_host: local_lan_ipv4(),
|
||||
#[cfg(debug_assertions)]
|
||||
subscription_identity: subscription_request_identity_for_display(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_singbox_subscription_to_storage(
|
||||
storage: &JsonStorage,
|
||||
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());
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
|
||||
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(
|
||||
storage: &JsonStorage,
|
||||
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()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"singbox_subscription_missing",
|
||||
"Ссылка на подписку Local sing-box не сохранена.",
|
||||
)
|
||||
})?;
|
||||
|
||||
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 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());
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_singbox_subscription_cache(&cache)
|
||||
.map_err(storage_error)?;
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?;
|
||||
let mut result = status_from_source(
|
||||
storage,
|
||||
&config,
|
||||
cache.as_ref(),
|
||||
detect_singbox_install().as_ref(),
|
||||
)?;
|
||||
result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?;
|
||||
let _ = storage.append_activity(ActivityEntry {
|
||||
id: "singbox-subscription-fetched".to_string(),
|
||||
at: clock.now(),
|
||||
level: ActivityLevel::Success,
|
||||
title: "Подписка Local sing-box обновлена".to_string(),
|
||||
message: format!(
|
||||
"Серверов найдено: {}",
|
||||
result.cache.as_ref().map_or(0, |cache| cache.servers.len())
|
||||
),
|
||||
});
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn forget_singbox_subscription_in_storage(
|
||||
storage: &JsonStorage,
|
||||
clock: &impl Clock,
|
||||
) -> Result<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;
|
||||
config.selected_server_id = None;
|
||||
config.updated_at = Some(clock.now());
|
||||
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)?;
|
||||
|
||||
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(
|
||||
storage: &JsonStorage,
|
||||
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
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty());
|
||||
if requested_tag.is_empty() {
|
||||
return Err(CommandError::new(
|
||||
"singbox_server_tag_missing",
|
||||
"Сервер Local sing-box не выбран.",
|
||||
));
|
||||
}
|
||||
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.map_err(storage_error)?
|
||||
.ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"singbox_subscription_cache_missing",
|
||||
"Сначала нужно загрузить подписку Local sing-box.",
|
||||
)
|
||||
})?;
|
||||
let Some(server) = find_subscription_server(
|
||||
&cache,
|
||||
requested_id,
|
||||
&requested_tag,
|
||||
input.server.as_deref(),
|
||||
input.server_port,
|
||||
) else {
|
||||
return Err(CommandError::new(
|
||||
"singbox_server_not_found",
|
||||
format!("Сервер Local sing-box '{requested_tag}' не найден в текущей подписке."),
|
||||
));
|
||||
};
|
||||
let selected_tag = server.tag.clone();
|
||||
let selected_id = server.id.clone();
|
||||
|
||||
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
|
||||
config.selected_server_tag = Some(selected_tag);
|
||||
config.selected_server_id = Some(selected_id);
|
||||
config.updated_at = Some(clock.now());
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.map_err(storage_error)?;
|
||||
|
||||
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(
|
||||
storage: &JsonStorage,
|
||||
input: PingSingBoxServerInputDto,
|
||||
) -> Result<PingServerResponse, CommandError> {
|
||||
let tag = input.tag.trim();
|
||||
let id = input
|
||||
.id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty());
|
||||
let cache = read_required_singbox_cache(storage)?;
|
||||
let server = find_subscription_server(&cache, id, tag, None, None).ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"singbox_server_not_found",
|
||||
format!("Сервер Local sing-box '{tag}' не найден в текущей подписке."),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(ping_subscription_server(server))
|
||||
}
|
||||
|
||||
pub fn ping_all_singbox_servers_in_storage(
|
||||
storage: &JsonStorage,
|
||||
) -> Result<Vec<PingServerResponse>, CommandError> {
|
||||
let cache = read_required_singbox_cache(storage)?;
|
||||
Ok(cache.servers.iter().map(ping_subscription_server).collect())
|
||||
}
|
||||
|
||||
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)?
|
||||
.ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"singbox_subscription_cache_missing",
|
||||
"Сначала нужно загрузить подписку Local sing-box.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError> {
|
||||
if subscription_url.is_empty() {
|
||||
return Err(CommandError::new(
|
||||
"singbox_subscription_url_missing",
|
||||
"Ссылка на подписку Local sing-box не указана.",
|
||||
));
|
||||
}
|
||||
|
||||
let parsed = url::Url::parse(subscription_url).map_err(|_| {
|
||||
CommandError::new(
|
||||
"singbox_subscription_url_invalid",
|
||||
"Ссылка на подписку Local sing-box должна быть корректным URL.",
|
||||
)
|
||||
})?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(CommandError::new(
|
||||
"singbox_subscription_url_invalid",
|
||||
"Ссылка на подписку Local sing-box должна начинаться с http:// или https://.",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_device_hwid(config: &mut LocalSingBoxConfig) -> bool {
|
||||
if config
|
||||
.device_hwid
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
config.device_hwid = Some(uuid::Uuid::new_v4().hyphenated().to_string().to_uppercase());
|
||||
true
|
||||
}
|
||||
|
||||
fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse {
|
||||
ping_endpoint(&server.id, &server.tag, &server.server, server.server_port)
|
||||
}
|
||||
|
||||
fn local_lan_ipv4() -> Option<String> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||
socket.connect("8.8.8.8:80").ok()?;
|
||||
let IpAddr::V4(address) = socket.local_addr().ok()?.ip() else {
|
||||
return None;
|
||||
};
|
||||
if address.is_loopback() || address.is_link_local() || address.is_unspecified() {
|
||||
return None;
|
||||
}
|
||||
Some(address.to_string())
|
||||
}
|
||||
|
||||
fn find_subscription_server<'a>(
|
||||
cache: &'a SubscriptionCache,
|
||||
requested_id: Option<&str>,
|
||||
requested_tag: &str,
|
||||
requested_server: Option<&str>,
|
||||
requested_port: Option<u16>,
|
||||
) -> Option<&'a SubscriptionServer> {
|
||||
if let Some(id) = requested_id {
|
||||
return cache.servers.iter().find(|server| server.id == id);
|
||||
}
|
||||
let tag = comparable_server_tag(requested_tag);
|
||||
let mut matches = cache.servers.iter().filter(|server| {
|
||||
comparable_server_tag(&server.tag) == tag
|
||||
&& requested_server.is_none_or(|host| server.server.eq_ignore_ascii_case(host.trim()))
|
||||
&& requested_port.is_none_or(|port| server.server_port == port)
|
||||
});
|
||||
if let Some(found) = matches.next() {
|
||||
return matches.next().is_none().then_some(found);
|
||||
}
|
||||
let host = requested_server?.trim();
|
||||
let port = requested_port?;
|
||||
let mut endpoints = cache
|
||||
.servers
|
||||
.iter()
|
||||
.filter(|server| server.server.eq_ignore_ascii_case(host) && server.server_port == port);
|
||||
let found = endpoints.next()?;
|
||||
endpoints.next().is_none().then_some(found)
|
||||
}
|
||||
|
||||
fn comparable_server_tag(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '\u{fe0e}' | '\u{fe0f}' | '\u{200d}'))
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn storage_error(error: std::io::Error) -> CommandError {
|
||||
CommandError::new("storage_error", error.to_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),
|
||||
}
|
||||
}
|
||||
|
||||
+272
-31
@@ -1,8 +1,7 @@
|
||||
use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer};
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use reqwest::redirect;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::net::{IpAddr, Ipv6Addr};
|
||||
use std::net::{IpAddr, Ipv6Addr, SocketAddr, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
@@ -11,6 +10,7 @@ const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsock
|
||||
const DEFAULT_APP_NAME: &str = "ProxyWarden";
|
||||
const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const SUBSCRIPTION_MAX_REDIRECTS: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionError {
|
||||
@@ -155,29 +155,16 @@ pub fn fetch_subscription_with_identity_and_policy(
|
||||
) -> Result<SubscriptionCache, SubscriptionError> {
|
||||
let parsed_url =
|
||||
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
|
||||
validate_subscription_fetch_url(&parsed_url, policy)?;
|
||||
let mut current_url = parsed_url;
|
||||
|
||||
let redirect_policy = redirect::Policy::custom(move |attempt| {
|
||||
if validate_subscription_fetch_url(attempt.url(), policy).is_ok() {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.stop()
|
||||
}
|
||||
});
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.connect_timeout(SUBSCRIPTION_CONNECT_TIMEOUT)
|
||||
.timeout(SUBSCRIPTION_REQUEST_TIMEOUT)
|
||||
.redirect(redirect_policy)
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription client setup failed: {error}"))
|
||||
})?;
|
||||
let mut request = client.get(parsed_url);
|
||||
for redirect_count in 0..=SUBSCRIPTION_MAX_REDIRECTS {
|
||||
validate_subscription_fetch_url(¤t_url, policy)?;
|
||||
let client = subscription_client_for_url(¤t_url, policy)?;
|
||||
let mut request = client.get(current_url.clone());
|
||||
|
||||
for (name, value) in identity.request_headers_without_device_hwid() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
|
||||
if let Some(device_hwid) = identity
|
||||
.device_hwid
|
||||
.as_deref()
|
||||
@@ -187,11 +174,31 @@ pub fn fetch_subscription_with_identity_and_policy(
|
||||
request = request.header("x-hwid", device_hwid);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
|
||||
|
||||
let response = request.send().map_err(|error| {
|
||||
SubscriptionError::new(format!(
|
||||
"Subscription request failed: {}",
|
||||
error.without_url()
|
||||
))
|
||||
})?;
|
||||
let status = response.status();
|
||||
if status.is_redirection() {
|
||||
if redirect_count == SUBSCRIPTION_MAX_REDIRECTS {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription request exceeded redirect limit",
|
||||
));
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
SubscriptionError::new("Subscription redirect has no valid Location header")
|
||||
})?;
|
||||
current_url = current_url
|
||||
.join(location)
|
||||
.map_err(|_| SubscriptionError::new("Subscription redirect URL is invalid"))?;
|
||||
continue;
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(SubscriptionError::new(format!(
|
||||
"Subscription request failed: HTTP {}",
|
||||
@@ -205,19 +212,75 @@ 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)?;
|
||||
|
||||
Ok(SubscriptionCache {
|
||||
return Ok(SubscriptionCache {
|
||||
config: parsed.config,
|
||||
servers: parsed.servers,
|
||||
user_info,
|
||||
fetched_at: now_timestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
Err(SubscriptionError::new(
|
||||
"Subscription request could not complete",
|
||||
))
|
||||
}
|
||||
|
||||
fn subscription_client_for_url(
|
||||
parsed_url: &Url,
|
||||
policy: SubscriptionFetchPolicy,
|
||||
) -> Result<reqwest::blocking::Client, SubscriptionError> {
|
||||
let mut builder = reqwest::blocking::Client::builder()
|
||||
.connect_timeout(SUBSCRIPTION_CONNECT_TIMEOUT)
|
||||
.timeout(SUBSCRIPTION_REQUEST_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
|
||||
if !policy.allow_unsafe_local_urls {
|
||||
let host = parsed_url
|
||||
.host_str()
|
||||
.ok_or_else(|| SubscriptionError::new("Subscription URL has no host"))?;
|
||||
if host.parse::<IpAddr>().is_err() {
|
||||
let port = parsed_url
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| SubscriptionError::new("Subscription URL has no resolvable port"))?;
|
||||
let addresses = (host, port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|error| {
|
||||
SubscriptionError::new(format!(
|
||||
"Subscription host DNS resolution failed: {error}"
|
||||
))
|
||||
})?
|
||||
.collect::<Vec<_>>();
|
||||
validate_resolved_subscription_addresses(&addresses)?;
|
||||
builder = builder.resolve_to_addrs(host, &addresses);
|
||||
}
|
||||
}
|
||||
|
||||
builder.build().map_err(|error| {
|
||||
SubscriptionError::new(format!("Subscription client setup failed: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_resolved_subscription_addresses(
|
||||
addresses: &[SocketAddr],
|
||||
) -> Result<(), SubscriptionError> {
|
||||
if addresses.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription host DNS resolution returned no addresses",
|
||||
));
|
||||
}
|
||||
if addresses.iter().any(|address| is_unsafe_ip(address.ip())) {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription host resolves to a local, private, link-local, multicast, or metadata address",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_subscription_fetch_url(
|
||||
parsed_url: &Url,
|
||||
policy: SubscriptionFetchPolicy,
|
||||
@@ -286,23 +349,171 @@ fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
|
||||
let links = decoded
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with("vless://"))
|
||||
.filter(|line| {
|
||||
["vless://", "trojan://", "ss://", "vmess://"]
|
||||
.iter()
|
||||
.any(|scheme| line.starts_with(scheme))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if links.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription does not contain JSON config or VLESS links",
|
||||
"Subscription does not contain JSON config or supported VLESS, VMess, Trojan, or Shadowsocks links",
|
||||
));
|
||||
}
|
||||
|
||||
let outbounds = links
|
||||
.into_iter()
|
||||
.map(parse_vless_url)
|
||||
.map(|link| {
|
||||
if link.starts_with("vless://") {
|
||||
parse_vless_url(link)
|
||||
} else if link.starts_with("trojan://") {
|
||||
parse_trojan_url(link)
|
||||
} else if link.starts_with("ss://") {
|
||||
parse_shadowsocks_url(link)
|
||||
} else {
|
||||
parse_vmess_url(link)
|
||||
}
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(json!({ "outbounds": outbounds }))
|
||||
}
|
||||
|
||||
fn parse_trojan_url(raw_url: &str) -> Result<Value, SubscriptionError> {
|
||||
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid Trojan URL"))?;
|
||||
let password = parsed.username().trim().to_string();
|
||||
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
|
||||
let server_port = parsed.port_or_known_default().unwrap_or(443);
|
||||
if password.is_empty() || server.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"Trojan URL misses password, host or port",
|
||||
));
|
||||
}
|
||||
let tag = parsed
|
||||
.fragment()
|
||||
.map(decode_percent_encoded_utf8)
|
||||
.unwrap_or_else(|| "trojan-out".to_string());
|
||||
let server_name = query_value(&parsed, "sni").unwrap_or_else(|| server.clone());
|
||||
|
||||
Ok(json!({
|
||||
"type": "trojan",
|
||||
"tag": tag,
|
||||
"server": server,
|
||||
"server_port": server_port,
|
||||
"password": password,
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": server_name
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_shadowsocks_url(raw_url: &str) -> Result<Value, SubscriptionError> {
|
||||
let parsed =
|
||||
Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid Shadowsocks URL"))?;
|
||||
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
|
||||
let server_port = parsed.port().unwrap_or(8388);
|
||||
let credentials = match parsed.password() {
|
||||
Some(password) => format!("{}:{password}", parsed.username()),
|
||||
None => decode_base64_text(parsed.username()).ok_or_else(|| {
|
||||
SubscriptionError::new("Shadowsocks credentials are not valid base64")
|
||||
})?,
|
||||
};
|
||||
let (method, password) = credentials
|
||||
.split_once(':')
|
||||
.ok_or_else(|| SubscriptionError::new("Shadowsocks URL misses method or password"))?;
|
||||
if method.trim().is_empty() || password.is_empty() || server.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"Shadowsocks URL misses method, password, host or port",
|
||||
));
|
||||
}
|
||||
let tag = parsed
|
||||
.fragment()
|
||||
.map(decode_percent_encoded_utf8)
|
||||
.unwrap_or_else(|| "shadowsocks-out".to_string());
|
||||
|
||||
Ok(json!({
|
||||
"type": "shadowsocks",
|
||||
"tag": tag,
|
||||
"server": server,
|
||||
"server_port": server_port,
|
||||
"method": method,
|
||||
"password": password
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_vmess_url(raw_url: &str) -> Result<Value, SubscriptionError> {
|
||||
let payload = raw_url
|
||||
.strip_prefix("vmess://")
|
||||
.and_then(|value| value.split('#').next())
|
||||
.ok_or_else(|| SubscriptionError::new("Invalid VMess URL"))?;
|
||||
let decoded = decode_base64_text(payload)
|
||||
.ok_or_else(|| SubscriptionError::new("VMess payload is not valid base64"))?;
|
||||
let source: Value = serde_json::from_str(&decoded)
|
||||
.map_err(|_| SubscriptionError::new("VMess payload is not valid JSON"))?;
|
||||
let server = source
|
||||
.get("add")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let server_port = source
|
||||
.get("port")
|
||||
.and_then(|value| value.as_u64().or_else(|| value.as_str()?.parse().ok()))
|
||||
.and_then(|value| u16::try_from(value).ok())
|
||||
.unwrap_or(443);
|
||||
let uuid = source.get("id").and_then(Value::as_str).unwrap_or_default();
|
||||
if server.is_empty() || uuid.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"VMess payload misses host, port or uuid",
|
||||
));
|
||||
}
|
||||
let tag = source
|
||||
.get("ps")
|
||||
.and_then(Value::as_str)
|
||||
.map(decode_percent_encoded_utf8)
|
||||
.unwrap_or_else(|| "vmess-out".to_string());
|
||||
let security = source
|
||||
.get("scy")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("auto");
|
||||
let mut outbound = json!({
|
||||
"type": "vmess",
|
||||
"tag": tag,
|
||||
"server": server,
|
||||
"server_port": server_port,
|
||||
"uuid": uuid,
|
||||
"security": security
|
||||
});
|
||||
if source.get("tls").and_then(Value::as_str) == Some("tls") {
|
||||
let server_name = source
|
||||
.get("sni")
|
||||
.or_else(|| source.get("host"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(server);
|
||||
outbound["tls"] = json!({ "enabled": true, "server_name": server_name });
|
||||
}
|
||||
if source.get("net").and_then(Value::as_str) == Some("ws") {
|
||||
let path = source
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("/");
|
||||
let host = source
|
||||
.get("host")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty());
|
||||
outbound["transport"] = json!({
|
||||
"type": "ws",
|
||||
"path": path,
|
||||
"headers": host.map(|host| json!({ "Host": host })).unwrap_or_else(|| json!({}))
|
||||
});
|
||||
}
|
||||
|
||||
Ok(outbound)
|
||||
}
|
||||
|
||||
fn parse_vless_url(raw_url: &str) -> Result<Value, SubscriptionError> {
|
||||
if !raw_url.starts_with("vless://") {
|
||||
return Err(SubscriptionError::new("VLESS URL must start with vless://"));
|
||||
@@ -399,6 +610,7 @@ fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
|
||||
.unwrap_or_else(|| format!("{server_type}-{server}"));
|
||||
|
||||
Some(SubscriptionServer {
|
||||
id: outbound_server_id(outbound),
|
||||
tag,
|
||||
server_type,
|
||||
server,
|
||||
@@ -406,6 +618,14 @@ fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
});
|
||||
format!("pw-{hash:016x}")
|
||||
}
|
||||
|
||||
fn maybe_decode_base64(content: &str) -> String {
|
||||
let compact = content.split_whitespace().collect::<String>();
|
||||
if compact.is_empty()
|
||||
@@ -419,7 +639,11 @@ fn maybe_decode_base64(content: &str) -> String {
|
||||
for engine in [general_purpose::STANDARD, general_purpose::URL_SAFE] {
|
||||
if let Ok(decoded) = engine.decode(compact.as_bytes()) {
|
||||
if let Ok(decoded) = String::from_utf8(decoded) {
|
||||
if decoded.contains("vless://") || decoded.contains('{') {
|
||||
if ["vless://", "vmess://", "trojan://", "ss://"]
|
||||
.iter()
|
||||
.any(|scheme| decoded.contains(scheme))
|
||||
|| decoded.contains('{')
|
||||
{
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
@@ -429,6 +653,23 @@ fn maybe_decode_base64(content: &str) -> String {
|
||||
content.to_string()
|
||||
}
|
||||
|
||||
fn decode_base64_text(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
for engine in [
|
||||
general_purpose::STANDARD,
|
||||
general_purpose::STANDARD_NO_PAD,
|
||||
general_purpose::URL_SAFE,
|
||||
general_purpose::URL_SAFE_NO_PAD,
|
||||
] {
|
||||
if let Ok(decoded) = engine.decode(value.as_bytes()) {
|
||||
if let Ok(decoded) = String::from_utf8(decoded) {
|
||||
return Some(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn query_value(url: &Url, key: &str) -> Option<String> {
|
||||
url.query_pairs()
|
||||
.find(|(name, _)| name == key)
|
||||
|
||||
@@ -25,11 +25,18 @@ fn clean(value: &str) -> String {
|
||||
fn slug(value: &str, fallback: &str) -> String {
|
||||
let mut output = String::new();
|
||||
let mut previous_dash = false;
|
||||
let mut has_non_ascii = false;
|
||||
|
||||
for ch in value.trim().to_lowercase().chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
output.push(ch);
|
||||
previous_dash = false;
|
||||
} else if ch.is_alphanumeric() {
|
||||
has_non_ascii = true;
|
||||
if !previous_dash {
|
||||
output.push('-');
|
||||
previous_dash = true;
|
||||
}
|
||||
} else if !previous_dash {
|
||||
output.push('-');
|
||||
previous_dash = true;
|
||||
@@ -37,13 +44,56 @@ fn slug(value: &str, fallback: &str) -> String {
|
||||
}
|
||||
|
||||
let output = output.trim_matches('-').to_string();
|
||||
if output.is_empty() {
|
||||
fallback.to_string()
|
||||
let base = if output.is_empty() { fallback } else { &output };
|
||||
if has_non_ascii {
|
||||
format!("{base}-{:016x}", stable_hash(value.trim().as_bytes()))
|
||||
} else {
|
||||
output
|
||||
base.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn stable_hash(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf29ce484222325, |hash, byte| {
|
||||
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_proxy_host(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& !value.contains("://")
|
||||
&& !value.chars().any(|ch| {
|
||||
ch.is_whitespace() || ch.is_control() || matches!(ch, '/' | '\\' | '@' | '?' | '#')
|
||||
})
|
||||
&& url::Host::parse(value).is_ok()
|
||||
}
|
||||
|
||||
fn valid_windows_item_path(value: &str, item_type: &ProfileItemType) -> bool {
|
||||
if value
|
||||
.chars()
|
||||
.any(|ch| ch.is_control() || matches!(ch, '"' | '<' | '>' | '|' | '?' | '*'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let bytes = value.as_bytes();
|
||||
let absolute_drive = bytes.len() >= 3
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[1] == b':'
|
||||
&& matches!(bytes[2], b'\\' | b'/');
|
||||
let unc = value.starts_with(r"\\");
|
||||
let environment_root = value.starts_with('%')
|
||||
&& value[1..].find('%').is_some_and(|index| {
|
||||
value
|
||||
.as_bytes()
|
||||
.get(index + 2)
|
||||
.is_some_and(|ch| matches!(ch, b'\\' | b'/'))
|
||||
});
|
||||
let path_shape_valid = absolute_drive || unc || environment_root;
|
||||
|
||||
path_shape_valid
|
||||
&& (!matches!(item_type, ProfileItemType::Exe)
|
||||
|| value.to_ascii_lowercase().ends_with(".exe"))
|
||||
}
|
||||
|
||||
fn process_name(value: &str) -> String {
|
||||
let base = value.trim().rsplit(['\\', '/']).next().unwrap_or("").trim();
|
||||
base.strip_suffix(".exe")
|
||||
@@ -149,6 +199,15 @@ pub fn normalize_profile(input: ProfileInput) -> ValidationResult<Profile> {
|
||||
errors.push(error("items.value", "Укажите значение элемента профиля"));
|
||||
continue;
|
||||
}
|
||||
if matches!(item_type, ProfileItemType::Folder | ProfileItemType::Exe)
|
||||
&& !valid_windows_item_path(&value, &item_type)
|
||||
{
|
||||
errors.push(error(
|
||||
"items.value",
|
||||
"Укажите абсолютный Windows-путь; для exe путь должен оканчиваться на .exe",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let recursive =
|
||||
matches!(item_type, ProfileItemType::Folder) && raw_item.recursive.unwrap_or(true);
|
||||
@@ -183,6 +242,11 @@ pub fn normalize_target(input: TargetInput) -> ValidationResult<Target> {
|
||||
}
|
||||
if host.is_empty() {
|
||||
errors.push(error("host", "Укажите хост цели"));
|
||||
} else if !valid_proxy_host(&host) {
|
||||
errors.push(error(
|
||||
"host",
|
||||
"Укажите только IP-адрес или имя хоста без схемы, пути и учетных данных",
|
||||
));
|
||||
}
|
||||
if input.port == 0 || input.port > u16::MAX as u32 {
|
||||
errors.push(error("port", "Порт цели должен быть от 1 до 65535"));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyWarden",
|
||||
"version": "1.0.2",
|
||||
"version": "2.0.0",
|
||||
"identifier": "ru.dokops.proxywarden.windows",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -27,7 +27,21 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": "nsis",
|
||||
"resources": [
|
||||
"bundled/components"
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "offlineInstaller",
|
||||
"silent": true
|
||||
},
|
||||
"nsis": {
|
||||
"installMode": "perMachine",
|
||||
"installerHooks": "bundled/installer-hooks/proxywarden-hooks.nsh",
|
||||
"template": "bundled/installer-hooks/installer-template.nsi"
|
||||
}
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter;
|
||||
use proxywarden_lib::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||
};
|
||||
use proxywarden_lib::apply_flow::{
|
||||
apply_configuration, ApplyConfigurationInput, ApplyPhaseStatus, ApplyRouteMode, ApplyServices,
|
||||
};
|
||||
use proxywarden_lib::commands::{
|
||||
Clock, CommandError, HelperApplyRequest, HelperApplyResult, ProxyApplyHelper,
|
||||
};
|
||||
use proxywarden_lib::component_detection::{DetectedProxyfier, ProxyfierEngine};
|
||||
use proxywarden_lib::models::{
|
||||
LocalSingBoxConfig, Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType,
|
||||
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};
|
||||
|
||||
#[test]
|
||||
fn external_apply_commits_one_source_state_without_service_control() {
|
||||
let fixture = ApplyFixture::new("external-success");
|
||||
fixture.seed_old_state();
|
||||
let helper = RecordingHelper::success();
|
||||
|
||||
let result =
|
||||
run_apply(&fixture.storage, external_input(), &helper).expect("preflight should succeed");
|
||||
|
||||
assert!(result.success);
|
||||
assert!(!result.partial_state);
|
||||
assert_eq!(helper.calls.get(), 1);
|
||||
assert!(result.phases.iter().any(|phase| {
|
||||
phase.id == "service-control" && phase.status == ApplyPhaseStatus::Skipped
|
||||
}));
|
||||
let profiles = fixture.storage.read_profiles().expect("read profiles");
|
||||
let targets = fixture.storage.read_targets().expect("read targets");
|
||||
assert!(profiles
|
||||
.iter()
|
||||
.any(|profile| profile.id == "main-profile" && profile.enabled));
|
||||
assert!(profiles
|
||||
.iter()
|
||||
.any(|profile| profile.id == "legacy" && !profile.enabled));
|
||||
assert!(targets.iter().any(|target| {
|
||||
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]
|
||||
fn preflight_failure_does_not_write_source_or_call_helper() {
|
||||
let fixture = ApplyFixture::new("preflight-failure");
|
||||
fixture.seed_old_state();
|
||||
let before_profiles = fixture.storage.read_profiles().expect("profiles before");
|
||||
let before_targets = fixture.storage.read_targets().expect("targets before");
|
||||
let helper = RecordingHelper::success();
|
||||
let mut input = external_input();
|
||||
input.external_target.as_mut().expect("target").host =
|
||||
"socks5://unsafe.example.test".to_string();
|
||||
|
||||
let error = run_apply(&fixture.storage, input, &helper)
|
||||
.expect_err("invalid target should fail before writes");
|
||||
|
||||
assert_eq!(error.code(), "validation_failed");
|
||||
assert_eq!(helper.calls.get(), 0);
|
||||
assert_eq!(
|
||||
fixture.storage.read_profiles().expect("profiles after"),
|
||||
before_profiles
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.storage.read_targets().expect("targets after"),
|
||||
before_targets
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_blocks_apply_when_proxifyre_is_not_detected() {
|
||||
let fixture = ApplyFixture::new("missing-proxifyre");
|
||||
fixture.seed_old_state();
|
||||
let helper = RecordingHelper::success();
|
||||
let proxy_adapter = ProxiFyreAdapter::default();
|
||||
let singbox_adapter = SingBoxAdapter::default();
|
||||
|
||||
let error = apply_configuration(
|
||||
&fixture.storage,
|
||||
external_input(),
|
||||
ApplyServices {
|
||||
proxy_adapter: &proxy_adapter,
|
||||
singbox_adapter: &singbox_adapter,
|
||||
checker: &NoopChecker,
|
||||
helper: &helper,
|
||||
clock: &FixedClock,
|
||||
detected_proxyfier: None,
|
||||
detected_singbox: None,
|
||||
},
|
||||
)
|
||||
.expect_err("backend must not trust frontend readiness");
|
||||
|
||||
assert_eq!(error.code(), "proxifyre_not_found");
|
||||
assert_eq!(helper.calls.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_command_contract_uses_camel_case_nested_dtos() {
|
||||
let input: ApplyConfigurationInput = serde_json::from_value(serde_json::json!({
|
||||
"routeMode": "external",
|
||||
"profile": {
|
||||
"id": "main-profile",
|
||||
"name": "Main",
|
||||
"enabled": true,
|
||||
"targetId": "main-proxy",
|
||||
"protocols": ["TCP"],
|
||||
"items": [{ "type": "process", "value": "Discord.exe" }]
|
||||
},
|
||||
"externalTarget": {
|
||||
"id": "main-proxy",
|
||||
"name": "Proxy",
|
||||
"kind": "external",
|
||||
"protocol": "socks5",
|
||||
"host": "proxy.example.test",
|
||||
"port": 1080
|
||||
},
|
||||
"disableOtherProfiles": true
|
||||
}))
|
||||
.expect("typed Tauri input should deserialize");
|
||||
|
||||
assert_eq!(input.profile.target_id, "main-proxy");
|
||||
assert_eq!(input.profile.items[0].item_type, "process");
|
||||
assert_eq!(
|
||||
input.external_target.expect("target").host,
|
||||
"proxy.example.test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_failure_rolls_back_source_and_generated_artifact() {
|
||||
let fixture = ApplyFixture::new("helper-rollback");
|
||||
fixture.seed_old_state();
|
||||
let before_profiles = fixture.storage.read_profiles().expect("profiles before");
|
||||
let before_targets = fixture.storage.read_targets().expect("targets before");
|
||||
let generated_path = fixture
|
||||
.storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join("proxifyre-app-config.json");
|
||||
fs::create_dir_all(generated_path.parent().expect("generated parent"))
|
||||
.expect("create generated dir");
|
||||
fs::write(&generated_path, b"old-generated").expect("seed generated config");
|
||||
|
||||
let helper = RecordingHelper::failure();
|
||||
let result = run_apply(&fixture.storage, external_input(), &helper)
|
||||
.expect("runtime failure should return phase result");
|
||||
|
||||
assert!(!result.success);
|
||||
assert!(!result.partial_state);
|
||||
assert_eq!(result.error_code.as_deref(), Some("fixture_apply_failed"));
|
||||
assert!(result
|
||||
.phases
|
||||
.iter()
|
||||
.any(|phase| phase.status == ApplyPhaseStatus::RolledBack));
|
||||
assert_eq!(
|
||||
fixture.storage.read_profiles().expect("profiles after"),
|
||||
before_profiles
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.storage.read_targets().expect("targets after"),
|
||||
before_targets
|
||||
);
|
||||
assert_eq!(
|
||||
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]
|
||||
fn local_apply_with_missing_running_service_stops_at_preflight() {
|
||||
let fixture = ApplyFixture::new("local-service-preflight");
|
||||
fixture.seed_old_state();
|
||||
fixture
|
||||
.storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/list".to_string()),
|
||||
selected_server_id: Some("fixture-server".to_string()),
|
||||
selected_server_tag: Some("fixture".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
.expect("write local config");
|
||||
fixture
|
||||
.storage
|
||||
.write_singbox_subscription_cache(&SubscriptionCache {
|
||||
config: serde_json::json!({
|
||||
"outbounds": [{
|
||||
"type": "vless",
|
||||
"tag": "fixture",
|
||||
"server": "edge.example.test",
|
||||
"server_port": 443,
|
||||
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||
}]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: "fixture-server".to_string(),
|
||||
tag: "fixture".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "edge.example.test".to_string(),
|
||||
server_port: 443,
|
||||
}],
|
||||
user_info: serde_json::Map::new(),
|
||||
fetched_at: "fixture".to_string(),
|
||||
})
|
||||
.expect("write cache");
|
||||
let helper = RecordingHelper::success();
|
||||
let before_profiles = fixture.storage.read_profiles().expect("profiles before");
|
||||
|
||||
let error = run_apply(
|
||||
&fixture.storage,
|
||||
ApplyConfigurationInput {
|
||||
expected_revision: None,
|
||||
route_mode: ApplyRouteMode::LocalSingbox,
|
||||
profile: profile_input(),
|
||||
external_target: None,
|
||||
disable_other_profiles: true,
|
||||
},
|
||||
&helper,
|
||||
)
|
||||
.expect_err("stopped/missing Local sing-box must block preflight");
|
||||
|
||||
assert_eq!(error.code(), "proxifyre_preflight_failed");
|
||||
assert_eq!(helper.calls.get(), 0);
|
||||
assert_eq!(
|
||||
fixture.storage.read_profiles().expect("profiles after"),
|
||||
before_profiles
|
||||
);
|
||||
}
|
||||
|
||||
#[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 {
|
||||
id: Some("main-proxy".to_string()),
|
||||
name: "Основной прокси".to_string(),
|
||||
kind: "external".to_string(),
|
||||
protocol: "socks5".to_string(),
|
||||
host: "proxy.example.test".to_string(),
|
||||
port: 1080,
|
||||
requires_component: None,
|
||||
}),
|
||||
disable_other_profiles: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_input() -> ProfileInput {
|
||||
ProfileInput {
|
||||
id: Some("main-profile".to_string()),
|
||||
name: "Приложения через прокси".to_string(),
|
||||
enabled: true,
|
||||
target_id: String::new(),
|
||||
protocols: vec!["TCP".to_string(), "UDP".to_string()],
|
||||
items: vec![ProfileItemInput {
|
||||
item_type: "process".to_string(),
|
||||
value: "Discord.exe".to_string(),
|
||||
recursive: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn run_apply(
|
||||
storage: &JsonStorage,
|
||||
input: ApplyConfigurationInput,
|
||||
helper: &dyn ProxyApplyHelper,
|
||||
) -> Result<
|
||||
proxywarden_lib::apply_flow::ApplyConfigurationResult,
|
||||
proxywarden_lib::apply_flow::ApplyFlowError,
|
||||
> {
|
||||
let proxy_adapter = ProxiFyreAdapter::default();
|
||||
let singbox_adapter = SingBoxAdapter::default();
|
||||
apply_configuration(
|
||||
storage,
|
||||
input,
|
||||
ApplyServices {
|
||||
proxy_adapter: &proxy_adapter,
|
||||
singbox_adapter: &singbox_adapter,
|
||||
checker: &NoopChecker,
|
||||
helper,
|
||||
clock: &FixedClock,
|
||||
detected_proxyfier: Some(test_proxyfier()),
|
||||
detected_singbox: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn test_proxyfier() -> DetectedProxyfier {
|
||||
DetectedProxyfier {
|
||||
engine: ProxyfierEngine::ProxiFyre,
|
||||
name: "ProxiFyre".to_string(),
|
||||
install_dir: r"C:\Program Files\ProxyWarden\components\ProxiFyre".into(),
|
||||
executable_path: r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe".into(),
|
||||
config_path: Some(
|
||||
r"C:\Program Files\ProxyWarden\components\ProxiFyre\app-config.json".into(),
|
||||
),
|
||||
running: true,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("running".to_string()),
|
||||
version: Some("2.2.1.0".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingHelper {
|
||||
calls: Cell<usize>,
|
||||
succeed: bool,
|
||||
}
|
||||
|
||||
impl RecordingHelper {
|
||||
fn success() -> Self {
|
||||
Self {
|
||||
calls: Cell::new(0),
|
||||
succeed: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure() -> Self {
|
||||
Self {
|
||||
calls: Cell::new(0),
|
||||
succeed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyApplyHelper for RecordingHelper {
|
||||
fn apply_proxy_config(
|
||||
&self,
|
||||
_request: HelperApplyRequest<'_>,
|
||||
) -> Result<HelperApplyResult, CommandError> {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
if self.succeed {
|
||||
Ok(HelperApplyResult {
|
||||
success: true,
|
||||
changed: true,
|
||||
action: "apply".to_string(),
|
||||
message: "fixture applied".to_string(),
|
||||
})
|
||||
} else {
|
||||
Err(CommandError {
|
||||
code: "fixture_apply_failed".to_string(),
|
||||
message: "fixture helper failed".to_string(),
|
||||
details: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopChecker;
|
||||
|
||||
impl SingBoxConfigChecker for NoopChecker {
|
||||
fn check_config(
|
||||
&self,
|
||||
_binary_path: &Path,
|
||||
_config_json: &str,
|
||||
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||
Ok(SingBoxCheckResult {
|
||||
checked: true,
|
||||
success: true,
|
||||
message: "fixture valid".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock;
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now(&self) -> String {
|
||||
"2026-07-11T00:00:00Z".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
struct ApplyFixture {
|
||||
root: std::path::PathBuf,
|
||||
storage: JsonStorage,
|
||||
}
|
||||
|
||||
impl ApplyFixture {
|
||||
fn new(label: &str) -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"proxywarden-apply-flow-{label}-{}",
|
||||
uuid::Uuid::new_v4().hyphenated()
|
||||
));
|
||||
Self {
|
||||
storage: JsonStorage::new(root.clone()),
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_old_state(&self) {
|
||||
self.storage
|
||||
.write_profiles(&[Profile {
|
||||
id: "legacy".to_string(),
|
||||
name: "Legacy".to_string(),
|
||||
enabled: true,
|
||||
target_id: "legacy-target".to_string(),
|
||||
protocols: vec![Protocol::Tcp],
|
||||
items: vec![ProfileItem {
|
||||
item_type: ProfileItemType::Process,
|
||||
value: "legacy".to_string(),
|
||||
recursive: false,
|
||||
}],
|
||||
}])
|
||||
.expect("seed profiles");
|
||||
self.storage
|
||||
.write_targets(&[Target {
|
||||
id: "legacy-target".to_string(),
|
||||
name: "Legacy".to_string(),
|
||||
kind: TargetKind::External,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host: "legacy.example.test".to_string(),
|
||||
port: 1080,
|
||||
requires_component: None,
|
||||
}])
|
||||
.expect("seed targets");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ApplyFixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
+143
-176
@@ -1,25 +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,
|
||||
};
|
||||
#[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]
|
||||
@@ -74,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");
|
||||
@@ -95,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);
|
||||
}
|
||||
@@ -151,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");
|
||||
@@ -217,93 +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 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("Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'"));
|
||||
assert!(script.contains("Invoke-ReleaseApi $ndisapiReleaseApi 'Windows Packet Filter'"));
|
||||
assert!(script.contains(
|
||||
"Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'"
|
||||
));
|
||||
assert!(script.contains("Invoke-ReleaseApi $proxifyreReleaseApi 'ProxiFyre'"));
|
||||
assert!(script.contains(
|
||||
"Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'"
|
||||
));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[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\sing-box".to_string(),
|
||||
"-ServiceName".to_string(),
|
||||
"ProxyWardenSingBox".to_string(),
|
||||
"-Uninstall".to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
assert!(script
|
||||
.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\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");
|
||||
@@ -314,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(),
|
||||
@@ -340,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 создан");
|
||||
@@ -357,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(),
|
||||
@@ -381,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(),
|
||||
@@ -390,6 +286,8 @@ fn component_status_merges_detected_existing_proxifyre() {
|
||||
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()),
|
||||
version: Some("2.2.1.0".to_string()),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
@@ -406,18 +304,51 @@ fn component_status_merges_detected_existing_proxifyre() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
|
||||
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)
|
||||
.expect("proxyfier component");
|
||||
|
||||
assert_eq!(proxyfier.state, ComponentState::Missing);
|
||||
assert!(!proxyfier.installed);
|
||||
assert!(!proxyfier.running);
|
||||
assert_eq!(proxyfier.path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 {
|
||||
@@ -427,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);
|
||||
}
|
||||
@@ -505,6 +480,7 @@ impl Clock for FixedClock {
|
||||
struct DetectionHost {
|
||||
paths: HashSet<String>,
|
||||
registry: Vec<RegistryInstallEntry>,
|
||||
service_paths: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl DetectionHost {
|
||||
@@ -525,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 {
|
||||
@@ -540,13 +522,34 @@ impl ProxyfierDetectionHost for DetectionHost {
|
||||
false
|
||||
}
|
||||
|
||||
fn service_running(&self, _service_name: &str) -> bool {
|
||||
false
|
||||
fn service_status(&self, _service_name: &str) -> Option<String> {
|
||||
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 {
|
||||
@@ -569,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(),
|
||||
@@ -615,31 +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()),
|
||||
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,
|
||||
problems: vec!["Локальный sing-box не установлен".to_string()],
|
||||
actions: vec!["Установить локальный sing-box".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
@@ -14,7 +22,13 @@ fn detects_existing_proxifyre_from_registry_install_location() {
|
||||
let host = MockHost::new()
|
||||
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre")
|
||||
.with_service("ProxiFyreService");
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.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");
|
||||
@@ -26,15 +40,94 @@ fn detects_existing_proxifyre_from_registry_install_location() {
|
||||
Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json"))
|
||||
);
|
||||
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);
|
||||
assert!(component.installed);
|
||||
assert!(component.running);
|
||||
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");
|
||||
|
||||
assert!(detect_proxyfier_install_with_host(&host).is_none());
|
||||
|
||||
let component = proxyfier_component_from_detection(None);
|
||||
assert_eq!(component.state, ComponentState::Missing);
|
||||
assert!(!component.installed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_plain_proxifier_install() {
|
||||
let host = MockHost::new()
|
||||
@@ -46,19 +139,76 @@ 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!(detect_proxyfier_install_with_host(&host).is_none());
|
||||
let inventory = inventory_proxyfier_with_host(&host);
|
||||
assert_eq!(inventory.classification(), ComponentClassification::Foreign);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_stopped_proxifyre_service_when_executable_exists() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.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");
|
||||
let component = proxyfier_component_from_detection(Some(&detected));
|
||||
|
||||
assert_eq!(component.state, ComponentState::Installed);
|
||||
assert!(component.installed);
|
||||
assert!(!component.running);
|
||||
assert_eq!(component.service_name, Some("ProxiFyreService".to_string()));
|
||||
assert_eq!(component.service_status, Some("stopped".to_string()));
|
||||
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!(detected.engine, ProxyfierEngine::ProxiFyre);
|
||||
assert_eq!(
|
||||
detected.executable_path,
|
||||
PathBuf::from(r"D:\Portable\ProxiFyre\ProxiFyre.exe")
|
||||
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]
|
||||
@@ -70,21 +220,71 @@ fn missing_proxyfier_returns_install_action_status() {
|
||||
assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_known_service_name_when_path_points_to_foreign_binary() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.with_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Foreign\ProxiFyre.exe" --service"#,
|
||||
);
|
||||
|
||||
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]
|
||||
fn ignores_known_service_name_without_path_metadata() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.with_service("ProxiFyreService");
|
||||
|
||||
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\sing-box\sing-box.exe")
|
||||
.with_service("ProxyWardenSingBox");
|
||||
.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:\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");
|
||||
|
||||
assert_eq!(
|
||||
detected.executable_path,
|
||||
PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe")
|
||||
PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe")
|
||||
);
|
||||
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);
|
||||
@@ -92,31 +292,105 @@ fn detects_running_local_singbox_from_default_install_root_and_service() {
|
||||
assert!(component.running);
|
||||
assert_eq!(
|
||||
component.path,
|
||||
Some(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe".to_string())
|
||||
Some(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe".to_string())
|
||||
);
|
||||
assert!(component.problems.is_empty());
|
||||
}
|
||||
|
||||
#[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!(component
|
||||
.problems
|
||||
.iter()
|
||||
.any(|problem| problem.contains("остановлена")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -130,12 +404,163 @@ 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>,
|
||||
paths: HashSet<String>,
|
||||
processes: HashSet<String>,
|
||||
services: 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>,
|
||||
}
|
||||
|
||||
@@ -160,7 +585,24 @@ impl MockHost {
|
||||
}
|
||||
|
||||
fn with_service(mut self, service: &str) -> Self {
|
||||
self.services.insert(service.to_ascii_lowercase());
|
||||
self.services
|
||||
.insert(service.to_ascii_lowercase(), "running".to_string());
|
||||
self
|
||||
}
|
||||
|
||||
fn with_service_path(mut self, service: &str, path_name: &str) -> Self {
|
||||
self.services
|
||||
.insert(service.to_ascii_lowercase(), "running".to_string());
|
||||
self.service_paths
|
||||
.insert(service.to_ascii_lowercase(), path_name.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
fn with_stopped_service_path(mut self, service: &str, path_name: &str) -> Self {
|
||||
self.services
|
||||
.insert(service.to_ascii_lowercase(), "stopped".to_string());
|
||||
self.service_paths
|
||||
.insert(service.to_ascii_lowercase(), path_name.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -172,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 {
|
||||
@@ -188,15 +648,116 @@ impl ProxyfierDetectionHost for MockHost {
|
||||
self.processes.contains(&process_name.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn service_running(&self, service_name: &str) -> bool {
|
||||
self.services.contains(&service_name.to_ascii_lowercase())
|
||||
fn service_status(&self, service_name: &str) -> Option<String> {
|
||||
self.services
|
||||
.get(&service_name.to_ascii_lowercase())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn service_info(
|
||||
&self,
|
||||
service_name: &str,
|
||||
) -> Option<proxywarden_lib::component_detection::DetectedService> {
|
||||
let key = service_name.to_ascii_lowercase();
|
||||
self.services.get(&key).map(|status| {
|
||||
proxywarden_lib::component_detection::DetectedService {
|
||||
name: service_name.to_string(),
|
||||
status: status.clone(),
|
||||
path_name: self.service_paths.get(&key).cloned(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -121,3 +121,91 @@ fn rejects_malformed_target_fields() {
|
||||
assert!(error.iter().any(|item| item.field == "protocol"));
|
||||
assert!(error.iter().any(|item| item.field == "requires_component"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_names_receive_distinct_stable_ids() {
|
||||
let profile = normalize_profile(ProfileInput {
|
||||
id: None,
|
||||
name: "Игры".to_string(),
|
||||
enabled: true,
|
||||
target_id: "main-proxy".to_string(),
|
||||
protocols: vec!["TCP".to_string()],
|
||||
items: vec![ProfileItemInput {
|
||||
item_type: "process".to_string(),
|
||||
value: "game.exe".to_string(),
|
||||
recursive: None,
|
||||
}],
|
||||
})
|
||||
.expect("unicode profile should normalize");
|
||||
let other = normalize_profile(ProfileInput {
|
||||
id: None,
|
||||
name: "Работа".to_string(),
|
||||
enabled: true,
|
||||
target_id: "main-proxy".to_string(),
|
||||
protocols: vec!["TCP".to_string()],
|
||||
items: vec![ProfileItemInput {
|
||||
item_type: "process".to_string(),
|
||||
value: "work.exe".to_string(),
|
||||
recursive: None,
|
||||
}],
|
||||
})
|
||||
.expect("second unicode profile should normalize");
|
||||
|
||||
assert!(profile.id.starts_with("profile-"));
|
||||
assert!(other.id.starts_with("profile-"));
|
||||
assert_ne!(profile.id, other.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_host_with_scheme_credentials_or_path() {
|
||||
for host in [
|
||||
"socks5://proxy.example.test",
|
||||
"user@proxy.example.test",
|
||||
"proxy.example.test/path",
|
||||
] {
|
||||
let error = normalize_target(TargetInput {
|
||||
id: None,
|
||||
name: "Invalid host".to_string(),
|
||||
kind: "external".to_string(),
|
||||
protocol: "socks5".to_string(),
|
||||
host: host.to_string(),
|
||||
port: 1080,
|
||||
requires_component: None,
|
||||
})
|
||||
.expect_err("host must not contain URL syntax");
|
||||
|
||||
assert!(error.iter().any(|item| item.field == "host"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_relative_or_non_executable_profile_paths() {
|
||||
let error = normalize_profile(ProfileInput {
|
||||
id: None,
|
||||
name: "Invalid paths".to_string(),
|
||||
enabled: true,
|
||||
target_id: "main-proxy".to_string(),
|
||||
protocols: vec!["TCP".to_string()],
|
||||
items: vec![
|
||||
ProfileItemInput {
|
||||
item_type: "folder".to_string(),
|
||||
value: r"relative\folder".to_string(),
|
||||
recursive: None,
|
||||
},
|
||||
ProfileItemInput {
|
||||
item_type: "exe".to_string(),
|
||||
value: r"C:\Games\game.txt".to_string(),
|
||||
recursive: None,
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect_err("unsafe path shapes should fail validation");
|
||||
|
||||
assert_eq!(
|
||||
error
|
||||
.iter()
|
||||
.filter(|item| item.field == "items.value")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user