From 1bb795a532f62756493083a71c981812a0358152 Mon Sep 17 00:00:00 2001 From: Dokril Date: Thu, 9 Jul 2026 11:51:16 +0300 Subject: [PATCH] Refactor ProxyWarden routing and settings flow --- .github/workflows/ci.yml | 60 ++++ package-lock.json | 452 +++++++++++++++++++++++++- package.json | 2 + rust-toolchain.toml | 3 + src-tauri/src/commands.rs | 159 ++++----- src-tauri/src/elevated_scripts.rs | 19 ++ src-tauri/src/lib.rs | 2 + src-tauri/src/models.rs | 35 +- src-tauri/src/safe_fs.rs | 53 +++ src-tauri/src/storage.rs | 100 ++++-- src-tauri/src/subscription.rs | 104 +++++- src-tauri/tauri.conf.json | 8 +- src-tauri/tests/command_tests.rs | 3 + src-tauri/tests/storage_tests.rs | 77 ++++- src-tauri/tests/subscription_tests.rs | 38 ++- src/app/App.tsx | 36 +- src/app/lib/parseProxy.test.ts | 43 +++ src/app/lib/parseProxy.ts | 34 ++ 18 files changed, 1018 insertions(+), 210 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 rust-toolchain.toml create mode 100644 src-tauri/src/elevated_scripts.rs create mode 100644 src-tauri/src/safe_fs.rs create mode 100644 src/app/lib/parseProxy.test.ts create mode 100644 src/app/lib/parseProxy.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..89bb407 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + windows-baseline: + name: Windows baseline + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Set up Rust toolchain + run: rustup show + + - name: Install frontend dependencies + run: npm ci + + - name: Run frontend tests + run: npm test -- --run + + - name: Build frontend + run: npm run build + + - name: Check Rust formatting + working-directory: src-tauri + run: cargo fmt --all -- --check + + - name: Run Rust lints + working-directory: src-tauri + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run Rust tests + working-directory: src-tauri + run: cargo test --all-targets + + - name: Check Tauri environment + run: npm run tauri -- info + + - name: Plan control app installer + shell: pwsh + run: .\scripts\install-control-app.ps1 -PlanOnly + + - name: Plan ProxiFyre installer + shell: pwsh + run: .\scripts\install-proxyfier.ps1 -PlanOnly + + - name: Plan sing-box installer + shell: pwsh + run: .\scripts\install-singbox.ps1 -PlanOnly diff --git a/package-lock.json b/package-lock.json index 5b9828f..c061d4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.0", "typescript": "^5.8.0", - "vite": "^7.0.0" + "vite": "^7.0.0", + "vitest": "^3.2.4" } }, "node_modules/@babel/code-frame": { @@ -1499,6 +1500,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1547,6 +1566,131 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.41", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", @@ -1594,6 +1738,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001800", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", @@ -1615,6 +1769,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1647,6 +1828,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.385", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz", @@ -1654,6 +1845,13 @@ "dev": true, "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -1706,6 +1904,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1782,6 +2000,13 @@ "node": ">=6" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1801,6 +2026,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1837,6 +2072,23 @@ "node": ">=18" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1978,6 +2230,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1988,6 +2247,54 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2005,6 +2312,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2125,6 +2462,119 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index a5ab3f3..990c0bd 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", + "test": "vitest", "tauri": "tauri" }, "dependencies": { @@ -24,6 +25,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.0", "typescript": "^5.8.0", + "vitest": "^3.2.4", "vite": "^7.0.0" } } diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..73cb934 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 010e898..74733b0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -12,12 +12,14 @@ use crate::component_detection::{ proxyfier_component_from_detection, singbox_component_from_detection, DetectedProxyfier, DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost, }; +use crate::elevated_scripts; use crate::models::{ ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind, }; use crate::process::command_no_window; +use crate::safe_fs; use crate::singbox_service::{ build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output as parse_singbox_service_command_output, service_control_script, @@ -718,54 +720,71 @@ pub fn select_singbox_server( } #[tauri::command] -pub fn ping_singbox_server( +pub async fn ping_singbox_server( state: tauri::State<'_, CommandState>, input: PingSingBoxServerInputDto, ) -> Result { - ping_singbox_server_in_storage(&state.storage(), input) + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || ping_singbox_server_in_storage(&storage, input)) + .await + .map_err(background_task_error)? } #[tauri::command] -pub fn ping_all_singbox_servers( +pub async fn ping_all_singbox_servers( state: tauri::State<'_, CommandState>, ) -> Result, CommandError> { - ping_all_singbox_servers_in_storage(&state.storage()) + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || ping_all_singbox_servers_in_storage(&storage)) + .await + .map_err(background_task_error)? } #[tauri::command] -pub fn ping_proxy_target( +pub async fn ping_proxy_target( input: PingProxyTargetInputDto, ) -> Result { - ping_proxy_target_endpoint(input) + tauri::async_runtime::spawn_blocking(move || ping_proxy_target_endpoint(input)) + .await + .map_err(background_task_error)? } #[tauri::command] -pub fn generate_singbox_config( +pub async fn generate_singbox_config( state: tauri::State<'_, CommandState>, ) -> Result { - let detected = detect_singbox_install(); - let binary_path = detected - .as_ref() - .map(|detected| detected.executable_path.as_path()); - generate_singbox_config_with_services( - &state.storage(), - &SingBoxAdapter::default(), - &SingBoxCommandChecker, - &SystemClock, - binary_path, - ) + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || { + let detected = detect_singbox_install(); + let binary_path = detected + .as_ref() + .map(|detected| detected.executable_path.as_path()); + generate_singbox_config_with_services( + &storage, + &SingBoxAdapter::default(), + &SingBoxCommandChecker, + &SystemClock, + binary_path, + ) + }) + .await + .map_err(background_task_error)? } #[tauri::command] -pub fn apply_profiles( +pub async fn apply_profiles( state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); - let adapter = ProxiFyreAdapter::default(); - let helper = DetectedProxyApplyHelper::system(); - let clock = SystemClock; + tauri::async_runtime::spawn_blocking(move || { + let adapter = ProxiFyreAdapter::default(); + let helper = DetectedProxyApplyHelper::system(); + let clock = SystemClock; - apply_profiles_with_services(&storage, &adapter, &helper, &clock) + apply_profiles_with_services(&storage, &adapter, &helper, &clock) + }) + .await + .map_err(background_task_error)? } #[tauri::command] @@ -2193,11 +2212,7 @@ fn write_elevated_singbox_service_script( config_source: Option<&Path>, config_target: Option<&Path>, ) -> Result { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or(0); - let script_path = env::temp_dir().join(format!("proxywarden-singbox-service-{nonce}.ps1")); + let script_path = elevated_scripts::temp_script_path("proxywarden-singbox-service"); let script = elevated_singbox_service_script(action, service_name, config_source, config_target); @@ -2374,10 +2389,6 @@ fn run_elevated_singbox_package_script( installer_args: Vec, artifact_dir: &Path, ) -> Result<(), CommandError> { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or(0); fs::create_dir_all(artifact_dir).map_err(|error| { CommandError::new( action.error_code(), @@ -2388,18 +2399,12 @@ fn run_elevated_singbox_package_script( ) })?; - let installer_path = artifact_dir.join(format!( - "proxywarden-singbox-{}-{nonce}.ps1", - action.file_label() - )); - let runner_path = artifact_dir.join(format!( - "proxywarden-singbox-{}-{nonce}.runner.ps1", - action.file_label() - )); - let result_path = artifact_dir.join(format!( - "proxywarden-singbox-{}-{nonce}.log", - action.file_label() - )); + let prefix = format!("proxywarden-singbox-{}", action.file_label()); + let installer_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1"); + let runner_path = + elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.runner"), "ps1"); + let result_path = + elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log"); write_powershell_script(&installer_path, installer_body).map_err(|error| { CommandError::new( @@ -2735,10 +2740,7 @@ fn resolved_app(item: &ProfileItem, warnings: &mut Vec) -> ResolvedAppDt } fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(storage_error)?; - } - fs::write(path, contents).map_err(storage_error) + safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error) } fn open_file_or_select(path: &Path) -> Result<(), CommandError> { @@ -3041,11 +3043,7 @@ fn write_elevated_service_script( action: ServiceControlAction, service_names: &[String], ) -> Result { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or(0); - let script_path = env::temp_dir().join(format!("proxywarden-proxifyre-service-{nonce}.ps1")); + let script_path = elevated_scripts::temp_script_path("proxywarden-proxifyre-service"); let script = elevated_service_script(action, service_names); write_powershell_script(&script_path, &script).map_err(|error| { @@ -3405,10 +3403,6 @@ fn run_elevated_package_script( body: String, artifact_dir: &Path, ) -> Result<(), CommandError> { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or(0); fs::create_dir_all(artifact_dir).map_err(|error| { CommandError::new( action.error_code(), @@ -3418,14 +3412,10 @@ fn run_elevated_package_script( ), ) })?; - let script_path = artifact_dir.join(format!( - "proxywarden-proxifyre-{}-{nonce}.ps1", - action.file_label() - )); - let result_path = artifact_dir.join(format!( - "proxywarden-proxifyre-{}-{nonce}.log", - action.file_label() - )); + let prefix = format!("proxywarden-proxifyre-{}", action.file_label()); + let script_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1"); + let result_path = + elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log"); let script = wrap_elevated_package_script(&body, &result_path); write_powershell_script(&script_path, &script).map_err(|error| { @@ -3845,46 +3835,17 @@ fn apply_to_detected_proxyfier( return staged_apply_result(request); }; - if let Some(parent) = config_path.parent() { - fs::create_dir_all(parent).map_err(|error| { + safe_fs::write_with_backup(config_path, request.config_contents.as_bytes()).map_err( + |error| { CommandError::new( "proxyfier_apply_failed", format!( - "Не удалось создать папку конфига ProxiFyre '{}': {error}", - parent.display() - ), - ) - })?; - } - - if config_path.exists() { - let backup_path = config_path.with_file_name(format!( - "{}.bak", - config_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("app-config.json") - )); - fs::copy(config_path, backup_path).map_err(|error| { - CommandError::new( - "proxyfier_apply_failed", - format!( - "Не удалось создать backup текущего конфига ProxiFyre '{}': {error}", + "Не удалось безопасно записать конфиг ProxiFyre '{}': {error}", config_path.display() ), ) - })?; - } - - fs::write(config_path, request.config_contents).map_err(|error| { - CommandError::new( - "proxyfier_apply_failed", - format!( - "Не удалось записать конфиг ProxiFyre '{}': {error}", - config_path.display() - ), - ) - })?; + }, + )?; Ok(HelperApplyResult { success: true, diff --git a/src-tauri/src/elevated_scripts.rs b/src-tauri/src/elevated_scripts.rs new file mode 100644 index 0000000..9bcae39 --- /dev/null +++ b/src-tauri/src/elevated_scripts.rs @@ -0,0 +1,19 @@ +use std::env; +use std::path::{Path, PathBuf}; + +pub fn temp_script_path(prefix: &str) -> PathBuf { + env::temp_dir().join(unique_file_name(prefix, "ps1")) +} + +pub fn artifact_path(artifact_dir: &Path, prefix: &str, extension: &str) -> PathBuf { + artifact_dir.join(unique_file_name(prefix, extension)) +} + +fn unique_file_name(prefix: &str, extension: &str) -> String { + let extension = extension.trim_start_matches('.'); + format!( + "{prefix}-{}.{}", + uuid::Uuid::new_v4().hyphenated(), + extension + ) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f2865a9..7f4ad2b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,11 @@ pub mod activity; pub mod commands; pub mod component_detection; +pub mod elevated_scripts; pub mod helper; pub mod models; pub mod process; +pub mod safe_fs; pub mod singbox_service; pub mod storage; pub mod subscription; diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index a3dfd4a..479af77 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1,6 +1,7 @@ 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; @@ -293,24 +294,22 @@ pub fn redact_subscription_url(raw_url: &str) -> String { return String::new(); } - match trimmed.split_once("://") { - Some((scheme, rest)) => { - let host = rest - .split(['/', '?', '#']) - .next() - .filter(|value| !value.is_empty()) - .unwrap_or("subscription"); - format!("{scheme}://{host}/...") - } - None => { - let visible = trimmed.chars().take(18).collect::(); - if trimmed.chars().count() <= 18 { - "***".to_string() - } else { - format!("{visible}...") - } - } - } + let Ok(parsed) = Url::parse(trimmed) else { + return "***".to_string(); + }; + + let host = parsed.host_str().unwrap_or("subscription"); + let host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + }; + let port = parsed + .port() + .map(|port| format!(":{port}")) + .unwrap_or_default(); + + format!("{}://{}{}/...", parsed.scheme(), host, port) } pub fn decode_percent_encoded_utf8(value: &str) -> String { diff --git a/src-tauri/src/safe_fs.rs b/src-tauri/src/safe_fs.rs new file mode 100644 index 0000000..fec407b --- /dev/null +++ b/src-tauri/src/safe_fs.rs @@ -0,0 +1,53 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +pub fn backup_path(path: &Path) -> PathBuf { + sibling_with_suffix(path, "bak") +} + +pub fn corrupt_path(path: &Path) -> PathBuf { + sibling_with_suffix( + path, + &format!("corrupt.{}", uuid::Uuid::new_v4().hyphenated()), + ) +} + +pub fn temp_path(path: &Path) -> PathBuf { + sibling_with_suffix(path, &format!("tmp.{}", uuid::Uuid::new_v4().hyphenated())) +} + +pub fn write_with_backup(path: &Path, contents: &[u8]) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let temp_path = temp_path(path); + fs::write(&temp_path, contents)?; + + let backup_path = backup_path(path); + if path.exists() { + fs::copy(path, &backup_path)?; + fs::remove_file(path)?; + } + + match fs::rename(&temp_path, path) { + Ok(()) => Ok(()), + Err(error) => { + let _ = fs::remove_file(&temp_path); + if !path.exists() && backup_path.exists() { + let _ = fs::copy(&backup_path, path); + } + Err(error) + } + } +} + +fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("proxywarden-file"); + + path.with_file_name(format!("{file_name}.{suffix}")) +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index d5202a1..7c1b622 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -2,6 +2,7 @@ use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT}; use crate::models::{ ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target, }; +use crate::safe_fs; use serde::{de::DeserializeOwned, Serialize}; use std::fs; use std::io::{self, ErrorKind}; @@ -144,10 +145,9 @@ impl JsonStorage { T: DeserializeOwned + Default, { match fs::read_to_string(path) { - Ok(contents) => match serde_json::from_str(&contents) { - Ok(value) => Ok(value), - Err(_) => Ok(T::default()), - }, + Ok(contents) => { + parse_json(path, &contents).or_else(|error| recover_corrupt_json(path, error)) + } Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()), Err(error) => Err(error), } @@ -167,7 +167,9 @@ impl JsonStorage { T: DeserializeOwned, { match fs::read_to_string(path) { - Ok(contents) => Ok(serde_json::from_str(&contents).ok()), + Ok(contents) => parse_json(path, &contents) + .map(Some) + .or_else(|error| recover_corrupt_json(path, error).map(Some)), Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } @@ -181,40 +183,72 @@ impl Default for JsonStorage { } pub fn backup_path(path: &Path) -> PathBuf { - sibling_with_suffix(path, "bak") -} - -fn temp_path(path: &Path) -> PathBuf { - sibling_with_suffix(path, "tmp") -} - -fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf { - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("storage.json"); - - path.with_file_name(format!("{file_name}.{suffix}")) + safe_fs::backup_path(path) } fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; + safe_fs::write_with_backup(path, contents) +} + +fn parse_json(path: &Path, contents: &str) -> io::Result +where + T: DeserializeOwned, +{ + serde_json::from_str(contents).map_err(|error| { + io::Error::new( + ErrorKind::InvalidData, + format!("Invalid JSON in '{}': {error}", path.display()), + ) + }) +} + +fn recover_corrupt_json(path: &Path, parse_error: io::Error) -> io::Result +where + T: DeserializeOwned, +{ + let corrupt_path = safe_fs::corrupt_path(path); + move_corrupt_file(path, &corrupt_path)?; + + let backup_path = backup_path(path); + if backup_path.exists() { + let backup_contents = fs::read_to_string(&backup_path)?; + match parse_json(&backup_path, &backup_contents) { + Ok(value) => { + fs::copy(&backup_path, path)?; + 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}", + path.display(), + corrupt_path.display(), + backup_path.display() + ), + )), + } + } else { + Err(io::Error::new( + ErrorKind::InvalidData, + format!( + "Invalid JSON in '{}'; corrupt file moved to '{}'; no valid backup available: {parse_error}", + path.display(), + corrupt_path.display() + ), + )) } +} - let temp_path = temp_path(path); - fs::write(&temp_path, contents)?; - - if path.exists() { - fs::copy(path, backup_path(path))?; - fs::remove_file(path)?; - } - - match fs::rename(&temp_path, path) { +fn move_corrupt_file(path: &Path, corrupt_path: &Path) -> io::Result<()> { + match fs::rename(path, corrupt_path) { Ok(()) => Ok(()), - Err(error) => { - let _ = fs::remove_file(&temp_path); - Err(error) + Err(rename_error) => { + fs::copy(path, corrupt_path)?; + fs::remove_file(path)?; + if !corrupt_path.exists() { + return Err(rename_error); + } + Ok(()) } } } diff --git a/src-tauri/src/subscription.rs b/src-tauri/src/subscription.rs index e02426b..0aeb411 100644 --- a/src-tauri/src/subscription.rs +++ b/src-tauri/src/subscription.rs @@ -1,11 +1,16 @@ 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::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; use url::Url; const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]; const DEFAULT_APP_NAME: &str = "ProxyWarden"; +const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); #[derive(Debug, Clone, PartialEq, Eq)] pub struct SubscriptionError { @@ -34,6 +39,11 @@ pub struct ParsedSubscription { pub servers: Vec, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SubscriptionFetchPolicy { + pub allow_unsafe_local_urls: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SubscriptionFetchIdentity { pub device_hwid: Option, @@ -134,16 +144,35 @@ pub fn fetch_subscription(url: &str) -> Result Result { + fetch_subscription_with_identity_and_policy(url, identity, SubscriptionFetchPolicy::default()) +} + +pub fn fetch_subscription_with_identity_and_policy( + url: &str, + identity: &SubscriptionFetchIdentity, + policy: SubscriptionFetchPolicy, ) -> Result { let parsed_url = Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?; - if !matches!(parsed_url.scheme(), "http" | "https") { - return Err(SubscriptionError::new( - "Subscription URL must use http or https", - )); - } + validate_subscription_fetch_url(&parsed_url, policy)?; - let mut request = reqwest::blocking::Client::new().get(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 (name, value) in identity.request_headers_without_device_hwid() { request = request.header(name, value); @@ -189,6 +218,69 @@ pub fn fetch_subscription_with_identity( }) } +fn validate_subscription_fetch_url( + parsed_url: &Url, + policy: SubscriptionFetchPolicy, +) -> Result<(), SubscriptionError> { + if !matches!(parsed_url.scheme(), "http" | "https") { + return Err(SubscriptionError::new( + "Subscription URL must use http or https", + )); + } + + if !policy.allow_unsafe_local_urls && is_unsafe_subscription_host(parsed_url) { + return Err(SubscriptionError::new( + "Subscription URL host is local, private, link-local, multicast, or metadata-only", + )); + } + + Ok(()) +} + +fn is_unsafe_subscription_host(parsed_url: &Url) -> bool { + let Some(host) = parsed_url.host_str() else { + return true; + }; + let host = host.trim_matches(['[', ']']).to_ascii_lowercase(); + + if matches!(host.as_str(), "localhost" | "metadata.google.internal") + || host.ends_with(".localhost") + { + return true; + } + + host.parse::().is_ok_and(is_unsafe_ip) +} + +fn is_unsafe_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_multicast() + || ip.is_broadcast() + || ip.is_unspecified() + || ip.octets() == [169, 254, 169, 254] + } + IpAddr::V6(ip) => { + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || is_unique_local_ipv6(ip) + || is_unicast_link_local_ipv6(ip) + } + } +} + +fn is_unique_local_ipv6(ip: Ipv6Addr) -> bool { + (ip.segments()[0] & 0xfe00) == 0xfc00 +} + +fn is_unicast_link_local_ipv6(ip: Ipv6Addr) -> bool { + (ip.segments()[0] & 0xffc0) == 0xfe80 +} + fn parse_link_subscription(body: &str) -> Result { let decoded = maybe_decode_base64(body); let links = decoded diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d6c1024..f9ae2a7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,16 +13,16 @@ "windows": [ { "title": "ProxyWarden", - "width": 820, + "width": 920, "height": 760, - "minWidth": 820, - "maxWidth": 820, + "minWidth": 760, + "maxWidth": 1200, "minHeight": 560, "resizable": true } ], "security": { - "csp": null + "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'" } }, "bundle": { diff --git a/src-tauri/tests/command_tests.rs b/src-tauri/tests/command_tests.rs index e931152..f0029d6 100644 --- a/src-tauri/tests/command_tests.rs +++ b/src-tauri/tests/command_tests.rs @@ -400,11 +400,14 @@ fn detected_proxy_apply_helper_writes_proxifyre_app_config() { 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()); cleanup(&root); diff --git a/src-tauri/tests/storage_tests.rs b/src-tauri/tests/storage_tests.rs index a23d44f..ba27dcb 100644 --- a/src-tauri/tests/storage_tests.rs +++ b/src-tauri/tests/storage_tests.rs @@ -159,7 +159,7 @@ fn reads_percent_encoded_singbox_tags_as_utf8() { } #[test] -fn invalid_subscription_cache_falls_back_to_none() { +fn invalid_subscription_cache_without_backup_returns_error_and_moves_corrupt_file() { let root = test_root("invalid-subscription-cache"); let storage = JsonStorage::new(root.clone()); fs::create_dir_all(&storage.paths().state_dir).expect("create state dir"); @@ -169,27 +169,62 @@ fn invalid_subscription_cache_falls_back_to_none() { ) .expect("write invalid cache"); - assert_eq!( - storage - .read_singbox_subscription_cache() - .expect("invalid cache fallback"), - None - ); + let error = storage + .read_singbox_subscription_cache() + .expect_err("invalid cache should not silently fallback"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(!storage.paths().singbox_subscription_cache_file.exists()); + assert!(has_corrupt_sibling( + &storage.paths().singbox_subscription_cache_file + )); cleanup(&root); } #[test] -fn invalid_json_falls_back_to_empty_collection() { - let root = test_root("invalid-json"); +fn invalid_json_without_backup_returns_error_and_moves_corrupt_file() { + let root = test_root("invalid-json-no-backup"); let storage = JsonStorage::new(root.clone()); fs::create_dir_all(&storage.paths().config_dir).expect("create config dir"); fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json"); + let error = storage + .read_profiles() + .expect_err("invalid profiles should not silently fallback"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(!storage.paths().profiles_file.exists()); + assert!(has_corrupt_sibling(&storage.paths().profiles_file)); + + cleanup(&root); +} + +#[test] +fn invalid_json_recovers_from_valid_backup() { + let root = test_root("invalid-json-valid-backup"); + let storage = JsonStorage::new(root.clone()); + let backup_profiles = vec![sample_profile("backup")]; + let current_profiles = vec![sample_profile("current")]; + + storage + .write_profiles(&backup_profiles) + .expect("write first profiles"); + storage + .write_profiles(¤t_profiles) + .expect("write second profiles"); + fs::write(&storage.paths().profiles_file, "{not valid json").expect("corrupt live json"); + + let recovered = storage + .read_profiles() + .expect("invalid profiles should recover from valid backup"); + + assert_eq!(recovered, backup_profiles); assert_eq!( - storage.read_profiles().expect("invalid profiles fallback"), - Vec::::new() + storage.read_profiles().expect("restored live profiles"), + backup_profiles ); + assert!(has_corrupt_sibling(&storage.paths().profiles_file)); cleanup(&root); } @@ -279,6 +314,26 @@ fn write_json(path: &Path, value: &T) { fs::write(path, contents).expect("write json"); } +fn has_corrupt_sibling(path: &Path) -> bool { + let Some(parent) = path.parent() else { + return false; + }; + let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else { + return false; + }; + let prefix = format!("{file_name}.corrupt."); + + fs::read_dir(parent) + .expect("read sibling dir") + .filter_map(Result::ok) + .any(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(&prefix)) + }) +} + fn sample_profile(id: &str) -> Profile { Profile { id: id.to_string(), diff --git a/src-tauri/tests/subscription_tests.rs b/src-tauri/tests/subscription_tests.rs index 4c45bb4..60f0533 100644 --- a/src-tauri/tests/subscription_tests.rs +++ b/src-tauri/tests/subscription_tests.rs @@ -2,6 +2,7 @@ use base64::{engine::general_purpose, Engine}; use proxywarden_lib::models::redact_subscription_url; use proxywarden_lib::subscription::{ self, parse_subscription_body, parse_user_info, SubscriptionFetchIdentity, + SubscriptionFetchPolicy, }; use std::io::{Read, Write}; use std::net::TcpListener; @@ -91,6 +92,25 @@ fn rejects_invalid_or_non_http_subscription_url_before_network() { assert!(unsupported.message.contains("http or https")); } +#[test] +fn rejects_unsafe_local_subscription_urls_before_network() { + for url in [ + "http://127.0.0.1:9/subscription", + "http://localhost/subscription", + "http://169.254.169.254/latest/meta-data", + "http://192.168.0.1/subscription", + "http://[::1]/subscription", + ] { + let error = subscription::fetch_subscription(url) + .expect_err("unsafe local URL should fail before request"); + assert!( + error.message.contains("local, private"), + "unexpected error for {url}: {}", + error.message + ); + } +} + #[test] fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener"); @@ -129,8 +149,14 @@ fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() { let mut identity = SubscriptionFetchIdentity::with_device_hwid(Some("hwid-abc123")); identity.device_os_version = Some("Windows 11 Pro | 25H2 | build 26200.8655".to_string()); - let cache = subscription::fetch_subscription_with_identity(&url, &identity) - .expect("fetch subscription through local test server"); + let cache = subscription::fetch_subscription_with_identity_and_policy( + &url, + &identity, + SubscriptionFetchPolicy { + allow_unsafe_local_urls: true, + }, + ) + .expect("fetch subscription through local test server"); let request = request_thread.join().expect("request thread"); assert_eq!(cache.servers[0].tag, "nl-1"); @@ -151,7 +177,13 @@ fn redacts_subscription_url_for_display() { ); assert_eq!( redact_subscription_url("vless://uuid@example.test"), - "vless://uuid@example.test/..." + "vless://example.test/..." + ); + assert_eq!( + redact_subscription_url( + "https://user:password@sub.example.test:8443/path?token=secret#frag" + ), + "https://sub.example.test:8443/..." ); } diff --git a/src/app/App.tsx b/src/app/App.tsx index b1ea6b9..88ea504 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -39,6 +39,7 @@ import { } from '../api/tauriCommands'; import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types'; import { BusyRing, Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui'; +import { parseProxy, type ParsedProxy } from './lib/parseProxy'; import { getApplyReadiness } from './readiness'; import { serviceControlState } from './viewModel'; @@ -1641,12 +1642,6 @@ export function App() { ); } -interface ParsedProxy { - protocol: 'socks5'; - host: string; - port: number; -} - interface SummaryStateInput { isLoading: boolean; isDetectingComponents: boolean; @@ -2337,35 +2332,6 @@ function changesApplyButtonLabel( return 'Применить изменения'; } -function parseProxy(rawValue: string): ParsedProxy { - const value = rawValue.trim(); - if (!value) throw new Error('Введи адрес прокси.'); - - const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`; - let parsed: URL; - try { - parsed = new URL(withProtocol); - } catch { - throw new Error('Формат: socks5://host:port или host:port.'); - } - - const protocol = parsed.protocol.replace(':', '').toLowerCase(); - if (protocol !== 'socks5') { - throw new Error('Сейчас поддерживается только SOCKS5.'); - } - if (parsed.username || parsed.password) { - throw new Error('Прокси с логином и паролем пока не поддерживаются.'); - } - - const host = parsed.hostname.replace(/^\[|\]$/g, ''); - const port = Number(parsed.port); - if (!host || !Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error('Укажи хост и порт прокси.'); - } - - return { protocol: 'socks5', host, port }; -} - function routeProxyCheckTarget( routeMode: RouteMode, proxyInput: string, diff --git a/src/app/lib/parseProxy.test.ts b/src/app/lib/parseProxy.test.ts new file mode 100644 index 0000000..248b4be --- /dev/null +++ b/src/app/lib/parseProxy.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { parseProxy } from './parseProxy'; + +describe('parseProxy', () => { + it('parses host and port without explicit protocol', () => { + expect(parseProxy('proxy.example.test:1080')).toEqual({ + protocol: 'socks5', + host: 'proxy.example.test', + port: 1080, + }); + }); + + it('parses socks5 URLs', () => { + expect(parseProxy('socks5://127.0.0.1:1080')).toEqual({ + protocol: 'socks5', + host: '127.0.0.1', + port: 1080, + }); + }); + + it('parses bracketed IPv6 hosts', () => { + expect(parseProxy('socks5://[::1]:1080')).toEqual({ + protocol: 'socks5', + host: '::1', + port: 1080, + }); + }); + + it('rejects unsupported schemes', () => { + expect(() => parseProxy('http://proxy.example.test:8080')).toThrow('SOCKS5'); + }); + + it('rejects missing or invalid ports', () => { + expect(() => parseProxy('proxy.example.test')).toThrow('хост и порт'); + expect(() => parseProxy('proxy.example.test:70000')).toThrow('Формат'); + }); + + it('rejects userinfo credentials', () => { + expect(() => parseProxy('socks5://user:password@proxy.example.test:1080')).toThrow( + 'логином и паролем', + ); + }); +}); diff --git a/src/app/lib/parseProxy.ts b/src/app/lib/parseProxy.ts new file mode 100644 index 0000000..f946532 --- /dev/null +++ b/src/app/lib/parseProxy.ts @@ -0,0 +1,34 @@ +export interface ParsedProxy { + protocol: 'socks5'; + host: string; + port: number; +} + +export function parseProxy(rawValue: string): ParsedProxy { + const value = rawValue.trim(); + if (!value) throw new Error('Введи адрес прокси.'); + + const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`; + let parsed: URL; + try { + parsed = new URL(withProtocol); + } catch { + throw new Error('Формат: socks5://host:port или host:port.'); + } + + const protocol = parsed.protocol.replace(':', '').toLowerCase(); + if (protocol !== 'socks5') { + throw new Error('Сейчас поддерживается только SOCKS5.'); + } + if (parsed.username || parsed.password) { + throw new Error('Прокси с логином и паролем пока не поддерживаются.'); + } + + const host = parsed.hostname.replace(/^\[|\]$/g, ''); + const port = Number(parsed.port); + if (!host || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('Укажи хост и порт прокси.'); + } + + return { protocol: 'socks5', host, port }; +}