diff --git a/biome.json b/biome.json index c18ac1ce..092cabc8 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "includes": [ diff --git a/package.json b/package.json index dbe58ee7..8a2f0acc 100644 --- a/package.json +++ b/package.json @@ -40,15 +40,15 @@ "node": ">=22.0.0" }, "dependencies": { - "@lucide/vue": "^1.23.0", + "@lucide/vue": "^1.28.0", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-barcode-scanner": "^2.4.5", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-deep-link": "^2.4.9", - "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-notification": "^2.3.3", - "@vueuse/core": "^14.3.0", - "cnfast": "^0.0.8", + "@vueuse/core": "^14.4.0", + "cnfast": "^0.1.0", "motion-v": "^2.3.0", "qrcode": "^1.5.4", "reka-ui": "^2.10.1", @@ -57,24 +57,24 @@ }, "devDependencies": { "@bany/curl-to-json": "^1.2.10", - "@tailwindcss/vite": "^4.3.2", + "@tailwindcss/vite": "^4.3.3", "@tauri-apps/cli": "^2.11.4", - "@types/node": "^26.1.0", + "@types/node": "^26.1.2", "@types/qrcode": "^1.5.6", - "@vitejs/plugin-vue": "^6.0.7", - "axios": "^1.18.1", + "@vitejs/plugin-vue": "^6.0.8", + "axios": "^1.19.0", "cfonts": "^3.3.1", - "chalk": "^5.6.2", - "i18next": "^26.3.4", + "chalk": "^6.0.0", + "i18next": "^26.3.6", "lodash": "^4.18.1", - "pinia": "^3.0.4", - "tailwindcss": "^4.3.2", - "terser": "^5.48.0", - "typescript": "^6.0.3", - "vite": "^8.1.3", - "vue": "^3.5.39", - "vue-router": "^5.1.0", - "vue-tsc": "^3.3.6", + "pinia": "^4.0.2", + "tailwindcss": "^4.3.3", + "terser": "^5.49.2", + "typescript": "^5.9.3", + "vite": "^8.2.0", + "vue": "^3.5.41", + "vue-router": "^5.2.0", + "vue-tsc": "^3.3.9", "vue-virtual-scroller": "3.0.4" }, "packageManager": "pnpm@11.3.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2405101e..d35eecdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@lucide/vue': - specifier: ^1.23.0 - version: 1.23.0(vue@3.5.39(typescript@6.0.3)) + specifier: ^1.28.0 + version: 1.28.0(vue@3.5.41(typescript@5.9.3)) '@tauri-apps/api': specifier: ^2.11.1 version: 2.11.1 @@ -24,26 +24,26 @@ importers: specifier: ^2.4.9 version: 2.4.9 '@tauri-apps/plugin-dialog': - specifier: ^2.7.1 - version: 2.7.1 + specifier: ^2.7.2 + version: 2.7.2 '@tauri-apps/plugin-notification': specifier: ^2.3.3 version: 2.3.3 '@vueuse/core': - specifier: ^14.3.0 - version: 14.3.0(vue@3.5.39(typescript@6.0.3)) + specifier: ^14.4.0 + version: 14.4.0(vue@3.5.41(typescript@5.9.3)) cnfast: - specifier: ^0.0.8 - version: 0.0.8 + specifier: ^0.1.0 + version: 0.1.0 motion-v: specifier: ^2.3.0 - version: 2.3.0(@vueuse/core@14.3.0(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + version: 2.3.0(@vueuse/core@14.4.0(vue@3.5.41(typescript@5.9.3)))(vue@3.5.41(typescript@5.9.3)) qrcode: specifier: ^1.5.4 version: 1.5.4 reka-ui: specifier: ^2.10.1 - version: 2.10.1(vue@3.5.39(typescript@6.0.3)) + version: 2.10.1(vue@3.5.41(typescript@5.9.3)) tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -55,62 +55,62 @@ importers: specifier: ^1.2.10 version: 1.2.10 '@tailwindcss/vite': - specifier: ^4.3.2 - version: 4.3.2(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + specifier: ^4.3.3 + version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0)) '@tauri-apps/cli': specifier: ^2.11.4 version: 2.11.4 '@types/node': - specifier: ^26.1.0 - version: 26.1.0 + specifier: ^26.1.2 + version: 26.1.2 '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 '@vitejs/plugin-vue': - specifier: ^6.0.7 - version: 6.0.7(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) + specifier: ^6.0.8 + version: 6.0.8(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) axios: - specifier: ^1.18.1 - version: 1.18.1 + specifier: ^1.19.0 + version: 1.19.0 cfonts: specifier: ^3.3.1 version: 3.3.1 chalk: - specifier: ^5.6.2 - version: 5.6.2 + specifier: ^6.0.0 + version: 6.0.0 i18next: - specifier: ^26.3.4 - version: 26.3.4(typescript@6.0.3) + specifier: ^26.3.6 + version: 26.3.6(typescript@5.9.3) lodash: specifier: ^4.18.1 version: 4.18.1 pinia: - specifier: ^3.0.4 - version: 3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)) + specifier: ^4.0.2 + version: 4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) tailwindcss: - specifier: ^4.3.2 - version: 4.3.2 + specifier: ^4.3.3 + version: 4.3.3 terser: - specifier: ^5.48.0 - version: 5.48.0 + specifier: ^5.49.2 + version: 5.49.2 typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: ^5.9.3 + version: 5.9.3 vite: - specifier: ^8.1.3 - version: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + specifier: ^8.2.0 + version: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0) vue: - specifier: ^3.5.39 - version: 3.5.39(typescript@6.0.3) + specifier: ^3.5.41 + version: 3.5.41(typescript@5.9.3) vue-router: - specifier: ^5.1.0 - version: 5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(rolldown@1.1.4)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) + specifier: ^5.2.0 + version: 5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)))(rolldown@1.2.3)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)) vue-tsc: - specifier: ^3.3.6 - version: 3.3.6(typescript@6.0.3) + specifier: ^3.3.9 + version: 3.3.9(typescript@5.9.3) vue-virtual-scroller: specifier: 3.0.4 - version: 3.0.4(vue@3.5.39(typescript@6.0.3)) + version: 3.0.4(vue@3.5.41(typescript@5.9.3)) packages/risuko-app: {} @@ -174,54 +174,45 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@8.0.2': - resolution: {integrity: sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==} + '@babel/helper-validator-identifier@8.0.4': + resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@8.0.0': - resolution: {integrity: sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==} + '@babel/parser@8.0.4': + resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} engines: {node: ^22.18.0 || >=24.11.0} hasBin: true - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@babel/types@8.0.0': - resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==} + '@babel/types@8.0.4': + resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} engines: {node: ^22.18.0 || >=24.11.0} '@bany/curl-to-json@1.2.10': resolution: {integrity: sha512-Hhc5O6gsTQOy1wpzO0gtM9w79/MQT571lI1Kkjd2aF77DG9uzp8v0n0DOhuLBTYIxO/DREV78U3v1vu9Q96ofA==} - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} '@floating-ui/vue@1.1.11': resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} - '@internationalized/date@3.12.2': - resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + '@internationalized/date@3.12.3': + resolution: {integrity: sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==} '@internationalized/number@3.6.7': resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} @@ -245,19 +236,13 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lucide/vue@1.23.0': - resolution: {integrity: sha512-9SIYeY5K+R1iv8F8JUKMGSL7Pck/86BJ8djtZz/lnYsoHtKFUj1H2z6+PvKnC/8blZ8tqTDeDXAoTKobuX80hg==} + '@lucide/vue@1.28.0': + resolution: {integrity: sha512-cO89UyNM/0i2Srdi04nFWeEOQkqzDZlM2ehtWyBhcMpRTJDKpMZPCGAVlmVBgBgr5HaeL5L6FsU8RHsOdwXtKw==} peerDependencies: vue: '>=3.0.1' - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@oxc-project/types@0.138.0': - resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} '@risuko/cli-darwin-arm64@0.6.0': resolution: {integrity: sha512-ARip8YEZ/H/Y5aia8uR88YikHdhWIQqfpGXiUlVYtjhZNjEp0Csud6pCaz/rzpSkR+l2Rj12ZiOG8O+6/Z9hFg==} @@ -321,97 +306,92 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-android-arm64@1.1.4': - resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} + '@rolldown/binding-android-arm64@1.2.3': + resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.4': - resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} + '@rolldown/binding-darwin-arm64@1.2.3': + resolution: {integrity: sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.4': - resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} + '@rolldown/binding-darwin-x64@1.2.3': + resolution: {integrity: sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.4': - resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} + '@rolldown/binding-freebsd-x64@1.2.3': + resolution: {integrity: sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.4': - resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + resolution: {integrity: sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.4': - resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} + '@rolldown/binding-linux-arm64-gnu@1.2.3': + resolution: {integrity: sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.4': - resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} + '@rolldown/binding-linux-arm64-musl@1.2.3': + resolution: {integrity: sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.4': - resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + resolution: {integrity: sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.4': - resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} + '@rolldown/binding-linux-s390x-gnu@1.2.3': + resolution: {integrity: sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.4': - resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} + '@rolldown/binding-linux-x64-gnu@1.2.3': + resolution: {integrity: sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.4': - resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} + '@rolldown/binding-linux-x64-musl@1.2.3': + resolution: {integrity: sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.4': - resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} + '@rolldown/binding-openharmony-arm64@1.2.3': + resolution: {integrity: sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.4': - resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.4': - resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} + '@rolldown/binding-win32-arm64-msvc@1.2.3': + resolution: {integrity: sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.4': - resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} + '@rolldown/binding-win32-x64-msvc@1.2.3': + resolution: {integrity: sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -422,69 +402,69 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - '@tailwindcss/node@4.3.2': - resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} - '@tailwindcss/oxide-android-arm64@4.3.2': - resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.3.2': - resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.3.2': - resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.3.2': - resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': - resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': - resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.3.2': - resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.3.2': - resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.3.2': - resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.3.2': - resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -495,32 +475,32 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': - resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.3.2': - resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.3.2': - resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} engines: {node: '>= 20'} - '@tailwindcss/vite@4.3.2': - resolution: {integrity: sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==} + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/virtual-core@3.17.3': - resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} + '@tanstack/virtual-core@3.17.7': + resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} - '@tanstack/vue-virtual@3.13.31': - resolution: {integrity: sha512-wZMEoSf852jQqaf3Ika1J7PiBae6341LNy/2CxmIyn0XKDQXMuK41wVX+xp6G0yx8jyR95Ef+Tdr13DK7mbJtQ==} + '@tanstack/vue-virtual@3.13.35': + resolution: {integrity: sha512-lOfSPvgPdlaH6Qy+CyIc3XpycitaSQ9GECndGpTuDiu+uDA1am+90yWXwzDSd/20ZM196ggWJLS+Qb6WjVd/OA==} peerDependencies: vue: ^2.7.0 || ^3.0.0 @@ -612,20 +592,17 @@ packages: '@tauri-apps/plugin-deep-link@2.4.9': resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==} - '@tauri-apps/plugin-dialog@2.7.1': - resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + '@tauri-apps/plugin-dialog@2.7.2': + resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==} '@tauri-apps/plugin-notification@2.3.3': resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} - '@types/node@26.1.0': - resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -633,8 +610,8 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} - '@vitejs/plugin-vue@6.0.7': - resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==} + '@vitejs/plugin-vue@6.0.8': + resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -649,8 +626,8 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} - '@vue-macros/common@3.1.2': - resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==} + '@vue-macros/common@3.1.4': + resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==} engines: {node: '>=20.19.0'} peerDependencies: vue: ^2.7.0 || ^3.2.25 @@ -658,71 +635,60 @@ packages: vue: optional: true - '@vue/compiler-core@3.5.39': - resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} - '@vue/compiler-dom@3.5.39': - resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} - '@vue/compiler-sfc@3.5.39': - resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==} + '@vue/compiler-sfc@3.5.41': + resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} - '@vue/compiler-ssr@3.5.39': - resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + '@vue/compiler-ssr@3.5.41': + resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} - '@vue/devtools-api@7.7.10': - resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + '@vue/devtools-api@8.2.1': + resolution: {integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==} - '@vue/devtools-api@8.1.5': - resolution: {integrity: sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==} + '@vue/devtools-kit@8.2.1': + resolution: {integrity: sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==} - '@vue/devtools-kit@7.7.10': - resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + '@vue/devtools-shared@8.2.1': + resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==} - '@vue/devtools-kit@8.1.5': - resolution: {integrity: sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==} + '@vue/language-core@3.3.9': + resolution: {integrity: sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==} - '@vue/devtools-shared@7.7.10': - resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + '@vue/reactivity@3.5.41': + resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} - '@vue/devtools-shared@8.1.5': - resolution: {integrity: sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==} + '@vue/runtime-core@3.5.41': + resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} - '@vue/language-core@3.3.6': - resolution: {integrity: sha512-LgBMZAy2sR3cQWknpyaxnI6yBkqDfLBPkbdhwRhQCvzfNJRQXPilgQIrdI/v4ytJ0sAq9bWhaPsjqBqneomJ3Q==} + '@vue/runtime-dom@3.5.41': + resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} - '@vue/reactivity@3.5.39': - resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==} + '@vue/server-renderer@3.5.41': + resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} - '@vue/runtime-core@3.5.39': - resolution: {integrity: sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==} + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} - '@vue/runtime-dom@3.5.39': - resolution: {integrity: sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==} - - '@vue/server-renderer@3.5.39': - resolution: {integrity: sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==} - peerDependencies: - vue: 3.5.39 - - '@vue/shared@3.5.39': - resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} - - '@vueuse/core@14.3.0': - resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + '@vueuse/core@14.4.0': + resolution: {integrity: sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==} peerDependencies: vue: ^3.5.0 - '@vueuse/metadata@14.3.0': - resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + '@vueuse/metadata@14.4.0': + resolution: {integrity: sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==} - '@vueuse/shared@14.3.0': - resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + '@vueuse/shared@14.4.0': + resolution: {integrity: sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==} peerDependencies: vue: ^3.5.0 - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -756,8 +722,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.18.1: - resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} @@ -778,9 +744,9 @@ packages: engines: {node: '>=10'} hasBin: true - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chalk@6.0.0: + resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==} + engines: {node: '>=22'} chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} @@ -789,8 +755,8 @@ packages: cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - cnfast@0.0.8: - resolution: {integrity: sha512-EjXKMfGfdwtV4AcNSQ6AwQaVzpC1B7IxeiwA3FlhTXz+YFlMKVi4c1JX9tgD2QOlahQXjB8KUXrBaYG+3v871Q==} + cnfast@0.1.0: + resolution: {integrity: sha512-rH0jBKeLkVrK7NsZ5Ba2l7WdMBmm1k0FMpABeXUU1PgTUxbz3261gEuCSsrYuXo4BwAe3yCcIbQ93YyS52lOGQ==} hasBin: true color-convert@2.0.1: @@ -813,10 +779,6 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} - copy-anything@4.0.5: - resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} - engines: {node: '>=18'} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -858,8 +820,8 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - enhanced-resolve@5.21.6: - resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} entities@7.0.1: @@ -885,8 +847,8 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - exsolve@1.1.0: - resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -914,8 +876,8 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} - framer-motion@12.42.2: - resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} + framer-motion@12.43.0: + resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -981,10 +943,10 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} - i18next@26.3.4: - resolution: {integrity: sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==} + i18next@26.3.6: + resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} peerDependencies: - typescript: ^5 || ^6 + typescript: ^5 || ^6 || ^7 peerDependenciesMeta: typescript: optional: true @@ -1012,10 +974,6 @@ packages: resolution: {integrity: sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==} engines: {node: '>=0.10.0'} - is-what@5.5.0: - resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} - engines: {node: '>=18'} - jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -1040,30 +998,60 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.32.0: resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.32.0: resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.32.0: resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} @@ -1071,6 +1059,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} @@ -1078,6 +1073,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} @@ -1085,6 +1087,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} @@ -1092,22 +1101,45 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.32.0: resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + local-pkg@1.2.1: resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} engines: {node: '>=14'} @@ -1141,14 +1173,11 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - mitt@3.0.1: - resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - motion-dom@12.42.2: - resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==} + motion-dom@12.43.0: + resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} motion-utils@12.39.0: resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} @@ -1165,11 +1194,14 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nostics@1.2.0: + resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -1195,9 +1227,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - perfect-debounce@1.0.0: - resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} - perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -1208,10 +1237,11 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - pinia@3.0.4: - resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + pinia@4.0.2: + resolution: {integrity: sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==} peerDependencies: - typescript: '>=4.5.0' + '@vue/devtools-api': ^8.1.5 + typescript: '>=5.6.0' vue: ^3.5.11 peerDependenciesMeta: typescript: @@ -1227,8 +1257,8 @@ packages: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} proxy-from-env@2.1.0: @@ -1259,11 +1289,8 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - - rolldown@1.1.4: - resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} + rolldown@1.2.3: + resolution: {integrity: sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1284,10 +1311,6 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - speakingurl@14.0.1: - resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} - engines: {node: '>=0.10.0'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1296,23 +1319,19 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - superjson@2.2.6: - resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} - engines: {node: '>=16'} - supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} - tailwindcss@4.3.2: - resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - terser@5.48.0: - resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + terser@5.49.2: + resolution: {integrity: sha512-rGbJiKeQ4WDe3EXlDAIaQcwftVfv2Q8o1awFNfvXolJYKkb1AuZY1RTOmqx4LJXZENbWZA7eIsYGHuEzHsi1nQ==} engines: {node: '>=10'} hasBin: true @@ -1326,8 +1345,8 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true @@ -1374,13 +1393,13 @@ packages: webpack: optional: true - vite@8.1.3: - resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -1431,14 +1450,14 @@ packages: '@vue/composition-api': optional: true - vue-router@5.1.0: - resolution: {integrity: sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==} + vue-router@5.2.0: + resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==} peerDependencies: '@pinia/colada': '>=0.21.2' - '@vue/compiler-sfc': ^3.5.34 - pinia: ^3.0.4 - vite: ^7.0.0 || ^8.0.0 - vue: ^3.5.34 + '@vue/compiler-sfc': ^3.5.34 || ^4.0.0 + pinia: ^3.0.4 || ^4.0.2 + vite: ^7.3.0 || ^8.0.0 + vue: ^3.5.34 || ^4.0.0 peerDependenciesMeta: '@pinia/colada': optional: true @@ -1463,8 +1482,8 @@ packages: nuxt: optional: true - vue-tsc@3.3.6: - resolution: {integrity: sha512-ERXGgbKSBGFUkavrJ1Iwj0ZVxKqB/5UOx65IXy7fPf2UsoI21n3ssQLwdvx8xyUGgJe9PvSZTM/FYzIdwUDPFA==} + vue-tsc@3.3.9: + resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -1474,8 +1493,8 @@ packages: peerDependencies: vue: ^3.3.0 - vue@3.5.39: - resolution: {integrity: sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==} + vue@3.5.41: + resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} peerDependencies: typescript: '*' peerDependenciesMeta: @@ -1517,8 +1536,8 @@ snapshots: '@babel/generator@8.0.0': dependencies: - '@babel/parser': 8.0.0 - '@babel/types': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 '@types/jsesc': 2.5.1 @@ -1530,67 +1549,51 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-identifier@8.0.2': {} + '@babel/helper-validator-identifier@8.0.4': {} - '@babel/parser@7.29.7': + '@babel/parser@7.29.8': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 - '@babel/parser@8.0.0': + '@babel/parser@8.0.4': dependencies: - '@babel/types': 8.0.0 + '@babel/types': 8.0.4 - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@8.0.0': + '@babel/types@8.0.4': dependencies: '@babel/helper-string-parser': 8.0.0 - '@babel/helper-validator-identifier': 8.0.2 + '@babel/helper-validator-identifier': 8.0.4 '@bany/curl-to-json@1.2.10': dependencies: minimist: 1.2.8 - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.11.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@floating-ui/core@1.7.5': + '@floating-ui/core@1.8.0': dependencies: - '@floating-ui/utils': 0.2.11 + '@floating-ui/utils': 0.2.12 - '@floating-ui/dom@1.7.6': + '@floating-ui/dom@1.8.0': dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 - '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.12': {} - '@floating-ui/vue@1.1.11(vue@3.5.39(typescript@6.0.3))': + '@floating-ui/vue@1.1.11(vue@3.5.41(typescript@5.9.3))': dependencies: - '@floating-ui/dom': 1.7.6 - '@floating-ui/utils': 0.2.11 - vue-demi: 0.14.10(vue@3.5.39(typescript@6.0.3)) + '@floating-ui/dom': 1.8.0 + '@floating-ui/utils': 0.2.12 + vue-demi: 0.14.10(vue@3.5.41(typescript@5.9.3)) transitivePeerDependencies: - '@vue/composition-api' - vue - '@internationalized/date@3.12.2': + '@internationalized/date@3.12.3': dependencies: '@swc/helpers': 0.5.23 @@ -1622,18 +1625,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lucide/vue@1.23.0(vue@3.5.39(typescript@6.0.3))': + '@lucide/vue@1.28.0(vue@3.5.41(typescript@5.9.3))': dependencies: - vue: 3.5.39(typescript@6.0.3) + vue: 3.5.41(typescript@5.9.3) - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - - '@oxc-project/types@0.138.0': {} + '@oxc-project/types@0.143.0': {} '@risuko/cli-darwin-arm64@0.6.0': optional: true @@ -1671,53 +1667,46 @@ snapshots: '@risuko/js-win32-x64-msvc@0.6.0': optional: true - '@rolldown/binding-android-arm64@1.1.4': + '@rolldown/binding-android-arm64@1.2.3': optional: true - '@rolldown/binding-darwin-arm64@1.1.4': + '@rolldown/binding-darwin-arm64@1.2.3': optional: true - '@rolldown/binding-darwin-x64@1.1.4': + '@rolldown/binding-darwin-x64@1.2.3': optional: true - '@rolldown/binding-freebsd-x64@1.1.4': + '@rolldown/binding-freebsd-x64@1.2.3': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.4': + '@rolldown/binding-linux-arm64-gnu@1.2.3': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.4': + '@rolldown/binding-linux-arm64-musl@1.2.3': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.4': + '@rolldown/binding-linux-ppc64-gnu@1.2.3': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.4': + '@rolldown/binding-linux-s390x-gnu@1.2.3': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.4': + '@rolldown/binding-linux-x64-gnu@1.2.3': optional: true - '@rolldown/binding-linux-x64-musl@1.1.4': + '@rolldown/binding-linux-x64-musl@1.2.3': optional: true - '@rolldown/binding-openharmony-arm64@1.1.4': + '@rolldown/binding-openharmony-arm64@1.2.3': optional: true - '@rolldown/binding-wasm32-wasi@1.1.4': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-win32-arm64-msvc@1.2.3': optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.4': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.1.4': + '@rolldown/binding-win32-x64-msvc@1.2.3': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -1726,80 +1715,80 @@ snapshots: dependencies: tslib: 2.8.1 - '@tailwindcss/node@4.3.2': + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.21.6 + enhanced-resolve: 5.24.5 jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.3.2 + tailwindcss: 4.3.3 - '@tailwindcss/oxide-android-arm64@4.3.2': + '@tailwindcss/oxide-android-arm64@4.3.3': optional: true - '@tailwindcss/oxide-darwin-arm64@4.3.2': + '@tailwindcss/oxide-darwin-arm64@4.3.3': optional: true - '@tailwindcss/oxide-darwin-x64@4.3.2': + '@tailwindcss/oxide-darwin-x64@4.3.3': optional: true - '@tailwindcss/oxide-freebsd-x64@4.3.2': + '@tailwindcss/oxide-freebsd-x64@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.3.2': + '@tailwindcss/oxide-linux-x64-musl@4.3.3': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.3.2': + '@tailwindcss/oxide-wasm32-wasi@4.3.3': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': optional: true - '@tailwindcss/oxide@4.3.2': + '@tailwindcss/oxide@4.3.3': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.2 - '@tailwindcss/oxide-darwin-arm64': 4.3.2 - '@tailwindcss/oxide-darwin-x64': 4.3.2 - '@tailwindcss/oxide-freebsd-x64': 4.3.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 - '@tailwindcss/oxide-linux-x64-musl': 4.3.2 - '@tailwindcss/oxide-wasm32-wasi': 4.3.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.2(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))': dependencies: - '@tailwindcss/node': 4.3.2 - '@tailwindcss/oxide': 4.3.2 - tailwindcss: 4.3.2 - vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0) - '@tanstack/virtual-core@3.17.3': {} + '@tanstack/virtual-core@3.17.7': {} - '@tanstack/vue-virtual@3.13.31(vue@3.5.39(typescript@6.0.3))': + '@tanstack/vue-virtual@3.13.35(vue@3.5.41(typescript@5.9.3))': dependencies: - '@tanstack/virtual-core': 3.17.3 - vue: 3.5.39(typescript@6.0.3) + '@tanstack/virtual-core': 3.17.7 + vue: 3.5.41(typescript@5.9.3) '@tauri-apps/api@2.11.1': {} @@ -1862,7 +1851,7 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 - '@tauri-apps/plugin-dialog@2.7.1': + '@tauri-apps/plugin-dialog@2.7.2': dependencies: '@tauri-apps/api': 2.11.1 @@ -1870,28 +1859,23 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - '@types/jsesc@2.5.1': {} - '@types/node@26.1.0': + '@types/node@26.1.2': dependencies: undici-types: 8.3.0 '@types/qrcode@1.5.6': dependencies: - '@types/node': 26.1.0 + '@types/node': 26.1.2 '@types/web-bluetooth@0.0.21': {} - '@vitejs/plugin-vue@6.0.7(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.8(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - vue: 3.5.39(typescript@6.0.3) + vite: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0) + vue: 3.5.41(typescript@5.9.3) '@volar/language-core@2.4.28': dependencies: @@ -1905,125 +1889,107 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 - '@vue-macros/common@3.1.2(vue@3.5.39(typescript@6.0.3))': + '@vue-macros/common@3.1.4(vue@3.5.41(typescript@5.9.3))': dependencies: - '@vue/compiler-sfc': 3.5.39 + '@vue/compiler-sfc': 3.5.41 ast-kit: 2.2.0 local-pkg: 1.2.1 magic-string-ast: 1.0.3 unplugin-utils: 0.3.2 optionalDependencies: - vue: 3.5.39(typescript@6.0.3) + vue: 3.5.41(typescript@5.9.3) - '@vue/compiler-core@3.5.39': + '@vue/compiler-core@3.5.41': dependencies: - '@babel/parser': 7.29.7 - '@vue/shared': 3.5.39 + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.39': + '@vue/compiler-dom@3.5.41': dependencies: - '@vue/compiler-core': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 - '@vue/compiler-sfc@3.5.39': + '@vue/compiler-sfc@3.5.41': dependencies: - '@babel/parser': 7.29.7 - '@vue/compiler-core': 3.5.39 - '@vue/compiler-dom': 3.5.39 - '@vue/compiler-ssr': 3.5.39 - '@vue/shared': 3.5.39 + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.41 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-ssr': 3.5.41 + '@vue/shared': 3.5.41 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.16 + postcss: 8.5.25 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.5.39': - dependencies: - '@vue/compiler-dom': 3.5.39 - '@vue/shared': 3.5.39 - - '@vue/devtools-api@7.7.10': + '@vue/compiler-ssr@3.5.41': dependencies: - '@vue/devtools-kit': 7.7.10 + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 - '@vue/devtools-api@8.1.5': + '@vue/devtools-api@8.2.1': dependencies: - '@vue/devtools-kit': 8.1.5 + '@vue/devtools-kit': 8.2.1 - '@vue/devtools-kit@7.7.10': + '@vue/devtools-kit@8.2.1': dependencies: - '@vue/devtools-shared': 7.7.10 - birpc: 2.9.0 - hookable: 5.5.3 - mitt: 3.0.1 - perfect-debounce: 1.0.0 - speakingurl: 14.0.1 - superjson: 2.2.6 - - '@vue/devtools-kit@8.1.5': - dependencies: - '@vue/devtools-shared': 8.1.5 + '@vue/devtools-shared': 8.2.1 birpc: 2.9.0 hookable: 5.5.3 perfect-debounce: 2.1.0 - '@vue/devtools-shared@7.7.10': - dependencies: - rfdc: 1.4.1 - - '@vue/devtools-shared@8.1.5': {} + '@vue/devtools-shared@8.2.1': {} - '@vue/language-core@3.3.6': + '@vue/language-core@3.3.9': dependencies: '@volar/language-core': 2.4.28 - '@vue/compiler-dom': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 alien-signals: 3.2.1 muggle-string: 0.4.1 path-browserify: 1.0.1 picomatch: 4.0.5 - '@vue/reactivity@3.5.39': + '@vue/reactivity@3.5.41': dependencies: - '@vue/shared': 3.5.39 + '@vue/shared': 3.5.41 - '@vue/runtime-core@3.5.39': + '@vue/runtime-core@3.5.41': dependencies: - '@vue/reactivity': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/reactivity': 3.5.41 + '@vue/shared': 3.5.41 - '@vue/runtime-dom@3.5.39': + '@vue/runtime-dom@3.5.41': dependencies: - '@vue/reactivity': 3.5.39 - '@vue/runtime-core': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/reactivity': 3.5.41 + '@vue/runtime-core': 3.5.41 + '@vue/shared': 3.5.41 csstype: 3.2.3 - '@vue/server-renderer@3.5.39(vue@3.5.39(typescript@6.0.3))': + '@vue/server-renderer@3.5.41': dependencies: - '@vue/compiler-ssr': 3.5.39 - '@vue/shared': 3.5.39 - vue: 3.5.39(typescript@6.0.3) + '@vue/compiler-ssr': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/shared': 3.5.41 - '@vue/shared@3.5.39': {} + '@vue/shared@3.5.41': {} - '@vueuse/core@14.3.0(vue@3.5.39(typescript@6.0.3))': + '@vueuse/core@14.4.0(vue@3.5.41(typescript@5.9.3))': dependencies: '@types/web-bluetooth': 0.0.21 - '@vueuse/metadata': 14.3.0 - '@vueuse/shared': 14.3.0(vue@3.5.39(typescript@6.0.3)) - vue: 3.5.39(typescript@6.0.3) + '@vueuse/metadata': 14.4.0 + '@vueuse/shared': 14.4.0(vue@3.5.41(typescript@5.9.3)) + vue: 3.5.41(typescript@5.9.3) - '@vueuse/metadata@14.3.0': {} + '@vueuse/metadata@14.4.0': {} - '@vueuse/shared@14.3.0(vue@3.5.39(typescript@6.0.3))': + '@vueuse/shared@14.4.0(vue@3.5.41(typescript@5.9.3))': dependencies: - vue: 3.5.39(typescript@6.0.3) + vue: 3.5.41(typescript@5.9.3) - acorn@8.17.0: {} + acorn@8.18.0: {} agent-base@6.0.2: dependencies: @@ -2045,18 +2011,18 @@ snapshots: ast-kit@2.2.0: dependencies: - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 pathe: 2.0.3 ast-walker-scope@0.9.0: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 ast-kit: 2.2.0 asynckit@0.4.0: {} - axios@1.18.1: + axios@1.19.0: dependencies: follow-redirects: 1.16.0 form-data: 4.0.6 @@ -2082,7 +2048,7 @@ snapshots: supports-color: 8.1.1 window-size: 1.1.1 - chalk@5.6.2: {} + chalk@6.0.0: {} chokidar@5.0.0: dependencies: @@ -2094,7 +2060,7 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 6.2.0 - cnfast@0.0.8: {} + cnfast@0.1.0: {} color-convert@2.0.1: dependencies: @@ -2112,10 +2078,6 @@ snapshots: confbox@0.2.4: {} - copy-anything@4.0.5: - dependencies: - is-what: 5.5.0 - csstype@3.2.3: {} debug@4.4.3: @@ -2144,7 +2106,7 @@ snapshots: emoji-regex@8.0.0: {} - enhanced-resolve@5.21.6: + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -2168,7 +2130,7 @@ snapshots: estree-walker@2.0.2: {} - exsolve@1.1.0: {} + exsolve@1.1.1: {} fdir@6.5.0(picomatch@4.0.5): optionalDependencies: @@ -2189,9 +2151,9 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 - framer-motion@12.42.2: + framer-motion@12.43.0: dependencies: - motion-dom: 12.42.2 + motion-dom: 12.43.0 motion-utils: 12.39.0 tslib: 2.8.1 @@ -2247,9 +2209,9 @@ snapshots: transitivePeerDependencies: - supports-color - i18next@26.3.4(typescript@6.0.3): + i18next@26.3.6(typescript@5.9.3): optionalDependencies: - typescript: 6.0.3 + typescript: 5.9.3 is-accessor-descriptor@1.0.2: dependencies: @@ -2272,8 +2234,6 @@ snapshots: dependencies: kind-of: 3.2.2 - is-what@5.5.0: {} - jiti@2.7.0: {} jsesc@3.1.0: {} @@ -2287,36 +2247,69 @@ snapshots: lightningcss-android-arm64@1.32.0: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.32.0: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.32.0: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.32.0: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.32.0: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.32.0: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.32.0: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.32.0: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.32.0: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.32.0: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -2333,6 +2326,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + local-pkg@1.2.1: dependencies: mlly: 1.8.2 @@ -2363,29 +2372,27 @@ snapshots: minimist@1.2.8: {} - mitt@3.0.1: {} - mlly@1.8.2: dependencies: - acorn: 8.17.0 + acorn: 8.18.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.4 - motion-dom@12.42.2: + motion-dom@12.43.0: dependencies: motion-utils: 12.39.0 motion-utils@12.39.0: {} - motion-v@2.3.0(@vueuse/core@14.3.0(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)): + motion-v@2.3.0(@vueuse/core@14.4.0(vue@3.5.41(typescript@5.9.3)))(vue@3.5.41(typescript@5.9.3)): dependencies: - '@vueuse/core': 14.3.0(vue@3.5.39(typescript@6.0.3)) - framer-motion: 12.42.2 + '@vueuse/core': 14.4.0(vue@3.5.41(typescript@5.9.3)) + framer-motion: 12.43.0 hey-listen: 1.0.8 - motion-dom: 12.42.2 + motion-dom: 12.43.0 motion-utils: 12.39.0 - vue: 3.5.39(typescript@6.0.3) + vue: 3.5.41(typescript@5.9.3) transitivePeerDependencies: - '@emotion/is-prop-valid' - react @@ -2395,7 +2402,9 @@ snapshots: muggle-string@0.4.1: {} - nanoid@3.3.15: {} + nanoid@3.3.17: {} + + nostics@1.2.0: {} ohash@2.0.11: {} @@ -2415,20 +2424,19 @@ snapshots: pathe@2.0.3: {} - perfect-debounce@1.0.0: {} - perfect-debounce@2.1.0: {} picocolors@1.1.1: {} picomatch@4.0.5: {} - pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)): + pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)): dependencies: - '@vue/devtools-api': 7.7.10 - vue: 3.5.39(typescript@6.0.3) + '@vue/devtools-api': 8.2.1 + nostics: 1.2.0 + vue: 3.5.41(typescript@5.9.3) optionalDependencies: - typescript: 6.0.3 + typescript: 5.9.3 pkg-types@1.3.1: dependencies: @@ -2439,14 +2447,14 @@ snapshots: pkg-types@2.3.1: dependencies: confbox: 0.2.4 - exsolve: 1.1.0 + exsolve: 1.1.1 pathe: 2.0.3 pngjs@5.0.0: {} - postcss@8.5.16: + postcss@8.5.25: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -2462,19 +2470,19 @@ snapshots: readdirp@5.0.0: {} - reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)): + reka-ui@2.10.1(vue@3.5.41(typescript@5.9.3)): dependencies: - '@floating-ui/dom': 1.7.6 - '@floating-ui/vue': 1.1.11(vue@3.5.39(typescript@6.0.3)) - '@internationalized/date': 3.12.2 + '@floating-ui/dom': 1.8.0 + '@floating-ui/vue': 1.1.11(vue@3.5.41(typescript@5.9.3)) + '@internationalized/date': 3.12.3 '@internationalized/number': 3.6.7 - '@tanstack/vue-virtual': 3.13.31(vue@3.5.39(typescript@6.0.3)) - '@vueuse/core': 14.3.0(vue@3.5.39(typescript@6.0.3)) - '@vueuse/shared': 14.3.0(vue@3.5.39(typescript@6.0.3)) + '@tanstack/vue-virtual': 3.13.35(vue@3.5.41(typescript@5.9.3)) + '@vueuse/core': 14.4.0(vue@3.5.41(typescript@5.9.3)) + '@vueuse/shared': 14.4.0(vue@3.5.41(typescript@5.9.3)) aria-hidden: 1.2.6 defu: 6.1.7 ohash: 2.0.11 - vue: 3.5.39(typescript@6.0.3) + vue: 3.5.41(typescript@5.9.3) transitivePeerDependencies: - '@vue/composition-api' @@ -2482,28 +2490,25 @@ snapshots: require-main-filename@2.0.0: {} - rfdc@1.4.1: {} - - rolldown@1.1.4: + rolldown@1.2.3: dependencies: - '@oxc-project/types': 0.138.0 + '@oxc-project/types': 0.143.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.4 - '@rolldown/binding-darwin-arm64': 1.1.4 - '@rolldown/binding-darwin-x64': 1.1.4 - '@rolldown/binding-freebsd-x64': 1.1.4 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 - '@rolldown/binding-linux-arm64-gnu': 1.1.4 - '@rolldown/binding-linux-arm64-musl': 1.1.4 - '@rolldown/binding-linux-ppc64-gnu': 1.1.4 - '@rolldown/binding-linux-s390x-gnu': 1.1.4 - '@rolldown/binding-linux-x64-gnu': 1.1.4 - '@rolldown/binding-linux-x64-musl': 1.1.4 - '@rolldown/binding-openharmony-arm64': 1.1.4 - '@rolldown/binding-wasm32-wasi': 1.1.4 - '@rolldown/binding-win32-arm64-msvc': 1.1.4 - '@rolldown/binding-win32-x64-msvc': 1.1.4 + '@rolldown/binding-android-arm64': 1.2.3 + '@rolldown/binding-darwin-arm64': 1.2.3 + '@rolldown/binding-darwin-x64': 1.2.3 + '@rolldown/binding-freebsd-x64': 1.2.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.3 + '@rolldown/binding-linux-arm64-gnu': 1.2.3 + '@rolldown/binding-linux-arm64-musl': 1.2.3 + '@rolldown/binding-linux-ppc64-gnu': 1.2.3 + '@rolldown/binding-linux-s390x-gnu': 1.2.3 + '@rolldown/binding-linux-x64-gnu': 1.2.3 + '@rolldown/binding-linux-x64-musl': 1.2.3 + '@rolldown/binding-openharmony-arm64': 1.2.3 + '@rolldown/binding-win32-arm64-msvc': 1.2.3 + '@rolldown/binding-win32-x64-msvc': 1.2.3 scule@1.3.0: {} @@ -2518,8 +2523,6 @@ snapshots: source-map@0.6.1: {} - speakingurl@14.0.1: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -2530,22 +2533,18 @@ snapshots: dependencies: ansi-regex: 5.0.1 - superjson@2.2.6: - dependencies: - copy-anything: 4.0.5 - supports-color@8.1.1: dependencies: has-flag: 4.0.0 - tailwindcss@4.3.2: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} - terser@5.48.0: + terser@5.49.2: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.17.0 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -2558,7 +2557,7 @@ snapshots: tw-animate-css@1.4.0: {} - typescript@6.0.3: {} + typescript@5.9.3: {} ufo@1.6.4: {} @@ -2569,40 +2568,40 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.5 - unplugin@3.3.0(rolldown@1.1.4)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): + unplugin@3.3.0(rolldown@1.2.3)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 optionalDependencies: - rolldown: 1.1.4 - vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + rolldown: 1.2.3 + vite: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0) - vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): + vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0): dependencies: - lightningcss: 1.32.0 + lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.16 - rolldown: 1.1.4 + postcss: 8.5.25 + rolldown: 1.2.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.0 + '@types/node': 26.1.2 fsevents: 2.3.3 jiti: 2.7.0 - terser: 5.48.0 + terser: 5.49.2 yaml: 2.9.0 vscode-uri@3.1.0: {} - vue-demi@0.14.10(vue@3.5.39(typescript@6.0.3)): + vue-demi@0.14.10(vue@3.5.41(typescript@5.9.3)): dependencies: - vue: 3.5.39(typescript@6.0.3) + vue: 3.5.41(typescript@5.9.3) - vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(rolldown@1.1.4)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)): + vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)))(rolldown@1.2.3)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0))(vue@3.5.41(typescript@5.9.3)): dependencies: '@babel/generator': 8.0.0 - '@vue-macros/common': 3.1.2(vue@3.5.39(typescript@6.0.3)) - '@vue/devtools-api': 8.1.5 + '@vue-macros/common': 3.1.4(vue@3.5.41(typescript@5.9.3)) + '@vue/devtools-api': 8.2.1 ast-walker-scope: 0.9.0 chokidar: 5.0.0 json5: 2.2.3 @@ -2610,18 +2609,19 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 muggle-string: 0.4.1 + nostics: 1.2.0 pathe: 2.0.3 picomatch: 4.0.5 scule: 1.3.0 tinyglobby: 0.2.17 - unplugin: 3.3.0(rolldown@1.1.4)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + unplugin: 3.3.0(rolldown@1.2.3)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0)) unplugin-utils: 0.3.2 - vue: 3.5.39(typescript@6.0.3) + vue: 3.5.41(typescript@5.9.3) yaml: 2.9.0 optionalDependencies: - '@vue/compiler-sfc': 3.5.39 - pinia: 3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)) - vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + '@vue/compiler-sfc': 3.5.41 + pinia: 4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) + vite: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(terser@5.49.2)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -2634,25 +2634,25 @@ snapshots: vue-sonner@2.0.9: {} - vue-tsc@3.3.6(typescript@6.0.3): + vue-tsc@3.3.9(typescript@5.9.3): dependencies: '@volar/typescript': 2.4.28 - '@vue/language-core': 3.3.6 - typescript: 6.0.3 + '@vue/language-core': 3.3.9 + typescript: 5.9.3 - vue-virtual-scroller@3.0.4(vue@3.5.39(typescript@6.0.3)): + vue-virtual-scroller@3.0.4(vue@3.5.41(typescript@5.9.3)): dependencies: - vue: 3.5.39(typescript@6.0.3) + vue: 3.5.41(typescript@5.9.3) - vue@3.5.39(typescript@6.0.3): + vue@3.5.41(typescript@5.9.3): dependencies: - '@vue/compiler-dom': 3.5.39 - '@vue/compiler-sfc': 3.5.39 - '@vue/runtime-dom': 3.5.39 - '@vue/server-renderer': 3.5.39(vue@3.5.39(typescript@6.0.3)) - '@vue/shared': 3.5.39 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-sfc': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/server-renderer': 3.5.41 + '@vue/shared': 3.5.41 optionalDependencies: - typescript: 6.0.3 + typescript: 5.9.3 webpack-virtual-modules@0.6.2: {} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0299aad1..ffceef20 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6856,6 +6856,7 @@ name = "risuko" version = "0.6.0" dependencies = [ "apple-native-keyring-store", + "async-trait", "base64 0.22.1", "clap", "dbus-secret-service-keyring-store", @@ -6981,6 +6982,7 @@ dependencies = [ "digest 0.11.3", "dirs 6.0.0", "feed-rs", + "fs4", "futures-util", "glob", "globset", @@ -7003,6 +7005,7 @@ dependencies = [ "risuko-http", "russh", "russh-sftp", + "rust-par2", "rustls", "serde", "serde_json", @@ -7012,6 +7015,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-rustls", "tokio-util", "tower-http 0.7.0", "tracing", @@ -7238,6 +7242,19 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rust-par2" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e385f48537be83896aa33de9bc92e8d0d40990701d0670fb043ac2ad6da54b65" +dependencies = [ + "crc32fast", + "md-5", + "rayon", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "rustc-hash" version = "2.1.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 72aa12b2..e38389cd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -83,6 +83,7 @@ sha1 = "0.11" keyring-core = "1" fs4 = "1" tempfile = "3" +async-trait = "0.1" [target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] apple-native-keyring-store = { version = "1", features = ["keychain"] } @@ -94,6 +95,7 @@ objc2-app-kit = { version = "0.3", features = ["NSWorkspace", "NSRunningApplicat windows-native-keyring-store = "1" windows-sys = { version = "0.61", features = [ "Win32_Foundation", + "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", diff --git a/src-tauri/risuko-cli/src/commands.rs b/src-tauri/risuko-cli/src/commands.rs index 9c16a2a5..b585bb59 100644 --- a/src-tauri/risuko-cli/src/commands.rs +++ b/src-tauri/risuko-cli/src/commands.rs @@ -590,6 +590,7 @@ async fn start_headless_engine( let config_dir = get_config_dir(); std::fs::create_dir_all(&config_dir)?; tracing::debug!("Config directory: {}", config_dir.display()); + risuko_engine::engine::set_file_usenet_credential_resolver(config_dir.clone()).await; let system_config = load_config(&config_dir.join("system.json"), defaults::system_defaults()); let user_config = load_config(&config_dir.join("user.json"), defaults::user_defaults()); diff --git a/src-tauri/risuko-engine/Cargo.toml b/src-tauri/risuko-engine/Cargo.toml index 804aa7bc..0faff9c5 100644 --- a/src-tauri/risuko-engine/Cargo.toml +++ b/src-tauri/risuko-engine/Cargo.toml @@ -41,6 +41,9 @@ risuko-http = { workspace = true } tokio-util = { version = "0.7", features = ["io"] } async-trait = "0.1" bytes = "1" +tempfile = "3" +rust-par2 = "=0.1.3" +fs4 = "1" url = "2" m3u8-rs = "6.0.0" quick-xml = { version = "0.40", features = ["serialize"] } @@ -50,7 +53,8 @@ feed-rs = "2.3.1" regex = "1.12.4" glob = "0.3" globset = "0.4" -rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } webpki-roots = "1" suppaftp = { version = "9", features = ["tokio-rustls-ring", "deprecated"] } russh = { version = "0.61", default-features = false, features = ["ring", "flate2"] } @@ -63,7 +67,6 @@ nix = { version = "0.31", features = ["fs"] } libc = "0.2" [dev-dependencies] -tempfile = "3" hyper = { version = "1", features = ["server", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } http-body-util = "0.1" diff --git a/src-tauri/risuko-engine/src/engine/archive_pipeline.rs b/src-tauri/risuko-engine/src/engine/archive_pipeline.rs new file mode 100644 index 00000000..b755ec33 --- /dev/null +++ b/src-tauri/risuko-engine/src/engine/archive_pipeline.rs @@ -0,0 +1,820 @@ +//! Usenet + +use crate::engine::archive_safety::{ + check_limits, validate_member_path, ArchiveLimits, ArchiveSafetyError, ArchiveUsage, +}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArchiveFormat { + Zip, + Rar, + SevenZip, + Tar, + Gzip, + Bzip2, + Xz, + Zstd, + Unknown, +} + +pub fn detect_archive_format(bytes: &[u8]) -> ArchiveFormat { + if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") { + ArchiveFormat::Zip + } else if bytes.starts_with(b"Rar!\x1a\x07\x00") || bytes.starts_with(b"Rar!\x1a\x07\x01\x00") { + ArchiveFormat::Rar + } else if bytes.starts_with(b"7z\xbc\xaf\x27\x1c") { + ArchiveFormat::SevenZip + } else if bytes.starts_with(&[0x1f, 0x8b]) { + ArchiveFormat::Gzip + } else if bytes.starts_with(b"BZh") { + ArchiveFormat::Bzip2 + } else if bytes.starts_with(&[0xfd, b'7', b'z', b'X', b'Z', 0x00]) { + ArchiveFormat::Xz + } else if bytes.starts_with(&[0x28, 0xb5, 0x2f, 0xfd]) { + ArchiveFormat::Zstd + } else if bytes.len() >= 262 && &bytes[257..262] == b"ustar" { + ArchiveFormat::Tar + } else { + ArchiveFormat::Unknown + } +} + +pub fn decode_yenc(input: &[u8]) -> Result, YEncError> { + let text = input; + let begin = find_line(text, b"=ybegin ").ok_or(YEncError::MissingBegin)?; + let end = find_line_after(text, b"=yend ", begin.1).ok_or(YEncError::MissingEnd)?; + let end_header = parse_header(&text[end.0..end.1]); + let expected_size = match end_header.get("size") { + Some(value) => Some( + value + .parse::() + .map_err(|_| YEncError::InvalidHeader)?, + ), + None => None, + }; + let expected_crc = end_header.get("crc32"); + + let mut out = Vec::new(); + let mut cursor = begin.1; + while cursor < end.0 { + let line_end = line_end(text, cursor); + let mut line = &text[cursor..line_end]; + if line.starts_with(b"=ypart ") || line.starts_with(b"=ybegin ") { + cursor = skip_line(text, line_end); + continue; + } + while let Some(&last) = line.last() { + if last == b'\r' || last == b'\n' { + line = &line[..line.len() - 1]; + } else { + break; + } + } + decode_yenc_line(line, &mut out).map_err(|error| match error { + "truncated yEnc escape" => YEncError::DanglingEscape, + _ => YEncError::InvalidHeader, + })?; + cursor = skip_line(text, line_end); + } + if let Some(size) = expected_size { + if out.len() != size { + return Err(YEncError::SizeMismatch { + expected: size, + actual: out.len(), + }); + } + } + if let Some(crc) = expected_crc { + let expected = u32::from_str_radix(crc, 16).map_err(|_| YEncError::InvalidCrc)?; + let actual = crate::engine::usenet_pipeline::crc32(&out); + if actual != expected { + return Err(YEncError::CrcMismatch { expected, actual }); + } + } + Ok(out) +} + +/// Decode one yEnc payload line. The worker's multipart decoder uses this +/// shared primitive so escaped bytes have identical semantics everywhere. +pub(crate) fn decode_yenc_line(input: &[u8], output: &mut Vec) -> Result<(), &'static str> { + let mut index = 0; + while index < input.len() { + let mut value = input[index]; + index += 1; + if value == b'=' { + if index >= input.len() { + return Err("truncated yEnc escape"); + } + value = input[index].wrapping_sub(64); + index += 1; + } + output.push(value.wrapping_sub(42)); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum YEncError { + MissingBegin, + MissingEnd, + InvalidHeader, + DanglingEscape, + InvalidCrc, + SizeMismatch { expected: usize, actual: usize }, + CrcMismatch { expected: u32, actual: u32 }, +} + +fn find_line(input: &[u8], marker: &[u8]) -> Option<(usize, usize)> { + find_line_after(input, marker, 0) +} + +fn find_line_after(input: &[u8], marker: &[u8], from: usize) -> Option<(usize, usize)> { + let mut pos = from; + while pos < input.len() { + let end = line_end(input, pos); + if input[pos..end].starts_with(marker) { + return Some((pos, end)); + } + pos = skip_line(input, end); + } + None +} + +fn line_end(input: &[u8], start: usize) -> usize { + input[start..] + .iter() + .position(|b| *b == b'\r' || *b == b'\n') + .map_or(input.len(), |offset| start + offset) +} + +fn skip_line(input: &[u8], end: usize) -> usize { + if input.get(end) == Some(&b'\r') && input.get(end + 1) == Some(&b'\n') { + end + 2 + } else if input.get(end).is_some() { + end + 1 + } else { + end + } +} + +fn parse_header(line: &[u8]) -> std::collections::HashMap { + let value = String::from_utf8_lossy(line); + value + .split_whitespace() + .skip(1) + .filter_map(|item| item.split_once('=')) + .map(|(key, value)| (key.to_ascii_lowercase(), value.to_string())) + .collect() +} + +/// Archive entry type supplied by a format reader +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArchiveEntryKind { + File, + Directory, + Symlink, + Hardlink, + Special, +} + +/// A decoded archive member +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArchiveMember { + pub path: String, + pub kind: ArchiveEntryKind, + pub data: Vec, + pub compressed_bytes: u64, + pub depth: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractionReport { + pub usage: ArchiveUsage, + pub files_written: u64, +} + +/// Validate and extract already-decoded members into `destination` +pub fn extract_members( + members: I, + destination: &Path, + limits: ArchiveLimits, + free_space_bytes: Option, +) -> Result +where + I: IntoIterator, +{ + extract_members_with_started_at( + members, + destination, + limits, + free_space_bytes, + Instant::now(), + ) +} + +fn extract_members_with_started_at( + members: I, + destination: &Path, + limits: ArchiveLimits, + free_space_bytes: Option, + started_at: Instant, +) -> Result +where + I: IntoIterator, +{ + fs::create_dir_all(destination).map_err(ArchivePipelineError::Io)?; + let mut usage = ArchiveUsage::default(); + let mut compressed_bytes = 0u64; + let mut files_written = 0; + for member in members { + validate_member_path(&member.path).map_err(ArchivePipelineError::Safety)?; + if matches!( + member.kind, + ArchiveEntryKind::Symlink | ArchiveEntryKind::Hardlink | ArchiveEntryKind::Special + ) { + return Err(ArchivePipelineError::UnsafeEntry(member.path)); + } + usage.entries = usage.entries.saturating_add(1); + usage.expanded_bytes = usage + .expanded_bytes + .saturating_add(member.data.len() as u64); + usage.max_entry_bytes = usage.max_entry_bytes.max(member.data.len() as u64); + usage.nesting_depth = usage.nesting_depth.max(member.depth); + compressed_bytes = compressed_bytes.saturating_add(member.compressed_bytes); + usage.active_seconds = started_at.elapsed().as_secs(); + check_limits(limits, usage, compressed_bytes, free_space_bytes) + .map_err(ArchivePipelineError::Safety)?; + let mut path = destination.join(&member.path); + if has_symlink_ancestor(destination, &member.path)? { + return Err(ArchivePipelineError::UnsafeEntry(member.path)); + } + match member.kind { + ArchiveEntryKind::Directory => { + fs::create_dir_all(&path).map_err(ArchivePipelineError::Io)? + } + ArchiveEntryKind::File => { + if path_exists(&path) { + path = collision_path(&path); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(ArchivePipelineError::Io)?; + } + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(ArchivePipelineError::Io)?; + file.write_all(&member.data) + .map_err(ArchivePipelineError::Io)?; + file.sync_all().map_err(ArchivePipelineError::Io)?; + // Never preserve executable bits from archive metadata + let mut perms = file + .metadata() + .map_err(ArchivePipelineError::Io)? + .permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + perms.set_mode(perms.mode() & 0o666); + } + file.set_permissions(perms) + .map_err(ArchivePipelineError::Io)?; + files_written += 1; + } + _ => unreachable!(), + } + usage.active_seconds = started_at.elapsed().as_secs(); + check_limits(limits, usage, compressed_bytes, free_space_bytes) + .map_err(ArchivePipelineError::Safety)?; + } + Ok(ExtractionReport { + usage, + files_written, + }) +} + +fn collision_path(path: &Path) -> PathBuf { + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("file"); + let extension = path.extension().and_then(|value| value.to_str()); + for index in 1..10_000u32 { + let name = match extension { + Some(extension) => format!("{stem} ({index}).{extension}"), + None => format!("{stem} ({index})"), + }; + let candidate = path.with_file_name(name); + if !path_exists(&candidate) { + return candidate; + } + } + path.with_file_name(format!("{stem}.{}", uuid::Uuid::new_v4().simple())) +} + +fn path_exists(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok() +} + +fn has_symlink_ancestor(destination: &Path, relative: &str) -> Result { + let mut current = destination.to_path_buf(); + for component in relative.split('/') { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => return Ok(true), + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(ArchivePipelineError::Io(error)), + } + } + Ok(false) +} + +#[derive(Debug)] +pub enum ArchivePipelineError { + Io(io::Error), + Safety(ArchiveSafetyError), + UnsafeEntry(String), +} + +impl std::fmt::Display for ArchivePipelineError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(err) => write!(f, "archive I/O: {err}"), + Self::Safety(err) => write!(f, "archive safety: {err:?}"), + Self::UnsafeEntry(path) => write!(f, "unsafe archive entry: {path}"), + } + } +} + +impl std::error::Error for ArchivePipelineError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Par2Outcome { + Verified, + Repaired, + MissingParity, + Unrecoverable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Par2Report { + pub outcome: Par2Outcome, + pub recovered_bytes: u64, +} + +pub trait Par2Verifier { + fn verify_or_repair( + &self, + data_files: &[PathBuf], + parity_files: &[PathBuf], + ) -> io::Result; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CleanupMode { + KeepAll, + DeletePar2, + #[serde(alias = "delete-par2-and-archives")] + DeletePar2AndVolumes, +} + +impl CleanupMode { + pub fn from_setting(value: Option<&str>) -> Self { + match value.unwrap_or_default() { + "delete-par2" => Self::DeletePar2, + "delete-par2-and-volumes" | "delete-par2-and-archives" => Self::DeletePar2AndVolumes, + _ => Self::KeepAll, + } + } +} + +pub fn is_archive_volume_name(name: &str) -> bool { + let name = Path::new(name) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(name) + .to_ascii_lowercase(); + let extension = Path::new(&name) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if matches!( + extension, + "zip" + | "rar" + | "rev" + | "7z" + | "tar" + | "gz" + | "tgz" + | "tbz" + | "tbz2" + | "bz2" + | "xz" + | "txz" + | "zst" + | "tzst" + ) { + return true; + } + let legacy_volume_digits = extension + .strip_prefix('r') + .or_else(|| extension.strip_prefix('z')); + if legacy_volume_digits.is_some_and(|digits| { + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) + }) { + return true; + } + if extension.bytes().all(|byte| byte.is_ascii_digit()) { + let without_volume = Path::new(&name).with_extension(""); + let container_extension = without_volume + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + return matches!(container_extension, "zip" | "7z" | "tar"); + } + false +} + +pub fn cleanup_after_success( + mode: CleanupMode, + verified_success: bool, + par2_files: &[PathBuf], + archive_volumes: &[PathBuf], +) -> io::Result<()> { + if !verified_success { + return Ok(()); + } + if matches!( + mode, + CleanupMode::DeletePar2 | CleanupMode::DeletePar2AndVolumes + ) { + remove_existing(par2_files)?; + } + if matches!(mode, CleanupMode::DeletePar2AndVolumes) { + remove_existing(archive_volumes)?; + } + Ok(()) +} + +fn remove_existing(paths: &[PathBuf]) -> io::Result<()> { + for path in paths { + if path.exists() { + fs::remove_file(path)?; + } + } + Ok(()) +} + +/// Durable, non-secret per-task resume metadata +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsenetResumeState { + pub manifest_fingerprint: String, + #[serde(default)] + pub segments: std::collections::BTreeMap, + #[serde(default)] + pub updated_at: u64, +} + +impl UsenetResumeState { + pub fn load(path: &Path) -> io::Result> { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } + } + + pub fn save_atomic(&mut self, path: &Path) -> io::Result<()> { + self.updated_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs(); + let bytes = serde_json::to_vec(self) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let tmp = path.with_extension(format!( + "{}tmp", + path.extension() + .and_then(|e| e.to_str()) + .map_or(String::new(), |e| format!("{e}.")) + )); + let mut file = fs::File::create(&tmp)?; + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + fs::rename(tmp, path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn yenc_encode(bytes: &[u8]) -> Vec { + let mut encoded = Vec::new(); + encoded.extend_from_slice( + format!("=ybegin line=128 size={} name=x.bin\r\n", bytes.len()).as_bytes(), + ); + for &byte in bytes { + let value = byte.wrapping_add(42); + if value == 0 || value == b'=' || value == b'\r' || value == b'\n' { + encoded.push(b'='); + encoded.push(value.wrapping_add(64)); + } else { + encoded.push(value); + } + } + encoded.extend_from_slice( + format!( + "\r\n=yend size={} crc32={:08x}\r\n", + bytes.len(), + crate::engine::usenet_pipeline::crc32(bytes) + ) + .as_bytes(), + ); + encoded + } + + #[test] + fn decodes_yenc_and_checks_crc() { + let source = b"hello\0world\n"; + let encoded = yenc_encode(source); + assert_eq!(decode_yenc(&encoded).unwrap(), source); + assert_eq!( + crate::engine::usenet_pipeline::decode_yenc(&encoded).unwrap(), + source + ); + let mut corrupt = encoded; + let crc_offset = corrupt + .windows(6) + .position(|window| window == b"crc32=") + .unwrap() + + 6; + corrupt[crc_offset] = if corrupt[crc_offset] == b'0' { + b'1' + } else { + b'0' + }; + assert!(decode_yenc(&corrupt).is_err()); + } + + #[test] + fn detects_supported_archive_headers() { + assert_eq!(detect_archive_format(b"PK\x03\x04..."), ArchiveFormat::Zip); + assert_eq!( + detect_archive_format(b"Rar!\x1a\x07\x01\x00..."), + ArchiveFormat::Rar + ); + assert_eq!( + detect_archive_format(b"7z\xbc\xaf\x27\x1c..."), + ArchiveFormat::SevenZip + ); + assert_eq!(detect_archive_format(&[0x1f, 0x8b]), ArchiveFormat::Gzip); + assert_eq!(detect_archive_format(b"unknown"), ArchiveFormat::Unknown); + } + + #[test] + fn rejects_unsafe_entries_and_enforces_limits() { + let dir = tempdir().unwrap(); + let limits = ArchiveLimits { + max_entries: 1, + max_expanded_bytes: 4, + max_entry_bytes: 4, + max_nesting_depth: 1, + max_compression_ratio: 10, + free_space_reserve_bytes: 0, + max_active_seconds: 10, + }; + let result = extract_members( + [ArchiveMember { + path: "../x".into(), + kind: ArchiveEntryKind::File, + data: vec![1], + compressed_bytes: 1, + depth: 0, + }], + dir.path(), + limits, + None, + ); + assert!(matches!( + result, + Err(ArchivePipelineError::Safety(ArchiveSafetyError::UnsafePath)) + )); + let result = extract_members( + [ArchiveMember { + path: "x".into(), + kind: ArchiveEntryKind::File, + data: vec![1, 2, 3, 4, 5], + compressed_bytes: 1, + depth: 0, + }], + dir.path(), + limits, + None, + ); + assert!(matches!( + result, + Err(ArchivePipelineError::Safety( + ArchiveSafetyError::ExpandedBytes | ArchiveSafetyError::EntryBytes + )) + )); + } + + #[test] + fn extraction_accounts_for_elapsed_active_time() { + let dir = tempdir().unwrap(); + let limits = ArchiveLimits { + max_entries: 1, + max_expanded_bytes: 10, + max_entry_bytes: 10, + max_nesting_depth: 1, + max_compression_ratio: 10, + free_space_reserve_bytes: 0, + max_active_seconds: 0, + }; + let result = extract_members_with_started_at( + [ArchiveMember { + path: "x".into(), + kind: ArchiveEntryKind::File, + data: vec![1], + compressed_bytes: 1, + depth: 0, + }], + dir.path(), + limits, + None, + Instant::now() - Duration::from_secs(1), + ); + assert!(matches!( + result, + Err(ArchivePipelineError::Safety(ArchiveSafetyError::ActiveTime)) + )); + } + + #[cfg(unix)] + #[test] + fn rejects_a_dangling_symlink_at_the_output_path() { + use std::os::unix::fs::symlink; + + let destination = tempdir().unwrap(); + let outside = tempdir().unwrap(); + let output = destination.path().join("x"); + symlink(outside.path().join("missing"), &output).unwrap(); + let limits = ArchiveLimits { + max_entries: 1, + max_expanded_bytes: 10, + max_entry_bytes: 10, + max_nesting_depth: 1, + max_compression_ratio: 10, + free_space_reserve_bytes: 0, + max_active_seconds: 10, + }; + + let result = extract_members( + [ArchiveMember { + path: "x".into(), + kind: ArchiveEntryKind::File, + data: vec![1], + compressed_bytes: 1, + depth: 0, + }], + destination.path(), + limits, + None, + ); + assert!(matches!(result, Err(ArchivePipelineError::UnsafeEntry(_)))); + assert!(!outside.path().join("missing").exists()); + } + + #[test] + fn resume_save_load_is_atomic_and_cleanup_is_success_gated() { + let dir = tempdir().unwrap(); + let path = dir.path().join("resume.json"); + let mut state = UsenetResumeState { + manifest_fingerprint: "abc".into(), + ..Default::default() + }; + state.segments.insert("1".into(), 42); + state.save_atomic(&path).unwrap(); + assert_eq!(UsenetResumeState::load(&path).unwrap().unwrap(), state); + let par2 = dir.path().join("x.par2"); + fs::write(&par2, b"x").unwrap(); + cleanup_after_success( + CleanupMode::DeletePar2, + false, + std::slice::from_ref(&par2), + &[], + ) + .unwrap(); + assert!(par2.exists()); + let archive = dir.path().join("x.part01.rar"); + fs::write(&archive, b"x").unwrap(); + cleanup_after_success( + CleanupMode::DeletePar2AndVolumes, + false, + std::slice::from_ref(&par2), + std::slice::from_ref(&archive), + ) + .unwrap(); + assert!(par2.exists() && archive.exists()); + cleanup_after_success( + CleanupMode::DeletePar2, + true, + std::slice::from_ref(&par2), + &[], + ) + .unwrap(); + assert!(!par2.exists()); + } + + #[test] + fn cleanup_modes_preserve_sidecars_and_non_archive_auxiliary_files() { + let dir = tempdir().unwrap(); + let par2 = dir.path().join("release.vol00+01.par2"); + let rar = dir.path().join("release.part01.rar"); + let zip_volume = dir.path().join("release.z01"); + let nfo = dir.path().join("release.nfo"); + let resume = dir.path().join("release.part01.rar.resume.json"); + for path in [&par2, &rar, &zip_volume, &nfo, &resume] { + fs::write(path, b"input").unwrap(); + } + + cleanup_after_success( + CleanupMode::KeepAll, + true, + std::slice::from_ref(&par2), + &[rar.clone(), zip_volume.clone()], + ) + .unwrap(); + assert!(par2.exists() && rar.exists() && zip_volume.exists()); + + cleanup_after_success( + CleanupMode::DeletePar2, + true, + std::slice::from_ref(&par2), + &[rar.clone(), zip_volume.clone()], + ) + .unwrap(); + assert!(!par2.exists()); + assert!(rar.exists() && zip_volume.exists()); + + fs::write(&par2, b"input").unwrap(); + cleanup_after_success( + CleanupMode::DeletePar2AndVolumes, + true, + std::slice::from_ref(&par2), + &[rar.clone(), zip_volume.clone()], + ) + .unwrap(); + assert!(!par2.exists() && !rar.exists() && !zip_volume.exists()); + assert!(nfo.exists() && resume.exists()); + } + + #[test] + fn cleanup_setting_accepts_frontend_archive_spelling() { + assert_eq!( + CleanupMode::from_setting(Some("delete-par2-and-archives")), + CleanupMode::DeletePar2AndVolumes + ); + assert_eq!( + CleanupMode::from_setting(Some("delete-par2-and-volumes")), + CleanupMode::DeletePar2AndVolumes + ); + assert_eq!( + CleanupMode::from_setting(Some("unknown")), + CleanupMode::KeepAll + ); + assert_eq!( + serde_json::from_str::("\"delete-par2-and-archives\"").unwrap(), + CleanupMode::DeletePar2AndVolumes + ); + } + + #[test] + fn archive_volume_classifier_excludes_auxiliary_files() { + assert!(is_archive_volume_name("release.part01.rar")); + assert!(is_archive_volume_name("release.r00")); + assert!(is_archive_volume_name("release.z01")); + assert!(is_archive_volume_name("release.7z.001")); + assert!(is_archive_volume_name("release.tar.gz")); + assert!(!is_archive_volume_name("release.nfo")); + assert!(!is_archive_volume_name("release.sfv")); + assert!(!is_archive_volume_name("release.par2")); + } +} diff --git a/src-tauri/risuko-engine/src/engine/archive_safety.rs b/src-tauri/risuko-engine/src/engine/archive_safety.rs new file mode 100644 index 00000000..ac8b2fc1 --- /dev/null +++ b/src-tauri/risuko-engine/src/engine/archive_safety.rs @@ -0,0 +1,346 @@ +//! Archive path and resource-limit validation shared by extraction workers + +use serde_json::Value; +use std::path::{Component, Path}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ArchiveLimits { + pub max_entries: u64, + pub max_expanded_bytes: u64, + pub max_entry_bytes: u64, + pub max_nesting_depth: u32, + pub max_compression_ratio: u64, + pub free_space_reserve_bytes: u64, + pub max_active_seconds: u64, +} + +impl ArchiveLimits { + pub const fn desktop_defaults() -> Self { + Self { + max_entries: 500_000, + max_expanded_bytes: 2 * 1024 * 1024 * 1024 * 1024, + max_entry_bytes: 512 * 1024 * 1024 * 1024, + max_nesting_depth: 16, + max_compression_ratio: 1000, + free_space_reserve_bytes: 10 * 1024 * 1024 * 1024, + max_active_seconds: 6 * 60 * 60, + } + } + + pub const fn android_defaults() -> Self { + Self { + max_entries: 100_000, + max_expanded_bytes: 256 * 1024 * 1024 * 1024, + max_entry_bytes: 64 * 1024 * 1024 * 1024, + max_nesting_depth: 16, + max_compression_ratio: 1000, + free_space_reserve_bytes: 2 * 1024 * 1024 * 1024, + max_active_seconds: 2 * 60 * 60, + } + } + + pub const fn hard_ceiling(self) -> Self { + Self { + max_entries: self.max_entries.saturating_mul(4), + max_expanded_bytes: self.max_expanded_bytes.saturating_mul(4), + max_entry_bytes: self.max_entry_bytes.saturating_mul(4), + max_nesting_depth: self.max_nesting_depth.saturating_mul(4), + max_compression_ratio: self.max_compression_ratio.saturating_mul(4), + free_space_reserve_bytes: self.free_space_reserve_bytes, + max_active_seconds: self.max_active_seconds.saturating_mul(4), + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ArchiveUsage { + pub entries: u64, + pub expanded_bytes: u64, + pub max_entry_bytes: u64, + pub nesting_depth: u32, + pub active_seconds: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArchiveSafetyError { + UnsafePath, + EntryCount, + ExpandedBytes, + EntryBytes, + NestingDepth, + CompressionRatio, + FreeSpaceReserve, + ActiveTime, + OverrideConfirmation, + HardCeiling, +} + +pub fn validate_limits_override( + defaults: ArchiveLimits, + requested: ArchiveLimits, + confirmed: bool, +) -> Result { + let ceiling = defaults.hard_ceiling(); + if requested.max_entries == 0 + || requested.max_expanded_bytes == 0 + || requested.max_entry_bytes == 0 + || requested.max_nesting_depth == 0 + || requested.max_compression_ratio == 0 + || requested.max_active_seconds == 0 + { + return Err(ArchiveSafetyError::HardCeiling); + } + if requested.max_entries > ceiling.max_entries + || requested.max_expanded_bytes > ceiling.max_expanded_bytes + || requested.max_entry_bytes > ceiling.max_entry_bytes + || requested.max_nesting_depth > ceiling.max_nesting_depth + || requested.max_compression_ratio > ceiling.max_compression_ratio + || requested.max_active_seconds > ceiling.max_active_seconds + { + return Err(ArchiveSafetyError::HardCeiling); + } + if !confirmed + && (requested.max_entries > defaults.max_entries + || requested.max_expanded_bytes > defaults.max_expanded_bytes + || requested.max_entry_bytes > defaults.max_entry_bytes + || requested.max_nesting_depth > defaults.max_nesting_depth + || requested.max_compression_ratio > defaults.max_compression_ratio + || requested.free_space_reserve_bytes < defaults.free_space_reserve_bytes + || requested.max_active_seconds > defaults.max_active_seconds) + { + return Err(ArchiveSafetyError::OverrideConfirmation); + } + Ok(requested) +} + +pub fn validate_limits_override_value( + defaults: ArchiveLimits, + value: &Value, + confirmed: bool, +) -> Result { + let object = value.as_object().ok_or(ArchiveSafetyError::HardCeiling)?; + let read = |key: &str, fallback: u64| -> Result { + match object.get(key) { + None => Ok(fallback), + Some(value) => value.as_u64().ok_or(ArchiveSafetyError::HardCeiling), + } + }; + let requested = ArchiveLimits { + max_entries: read("maxEntries", defaults.max_entries)?, + max_expanded_bytes: read("maxExpandedBytes", defaults.max_expanded_bytes)?, + max_entry_bytes: read("maxEntryBytes", defaults.max_entry_bytes)?, + max_nesting_depth: u32::try_from(read( + "maxNestingDepth", + defaults.max_nesting_depth as u64, + )?) + .map_err(|_| ArchiveSafetyError::HardCeiling)?, + max_compression_ratio: read("maxCompressionRatio", defaults.max_compression_ratio)?, + free_space_reserve_bytes: read("freeSpaceReserveBytes", defaults.free_space_reserve_bytes)?, + max_active_seconds: read("maxActiveSeconds", defaults.max_active_seconds)?, + }; + validate_limits_override(defaults, requested, confirmed) +} + +pub fn validate_member_path(path: &str) -> Result<(), ArchiveSafetyError> { + let normalized = path.trim_end_matches('/'); + if normalized.is_empty() + || normalized.contains('\0') + || normalized.contains('\\') + || normalized.contains(':') + || normalized.starts_with("//") + || Path::new(normalized).is_absolute() + { + return Err(ArchiveSafetyError::UnsafePath); + } + if normalized + .split('/') + .any(|part| part.is_empty() || part == "..") + { + return Err(ArchiveSafetyError::UnsafePath); + } + if Path::new(normalized) + .components() + .any(|component| matches!(component, Component::RootDir | Component::Prefix(_))) + { + return Err(ArchiveSafetyError::UnsafePath); + } + if path.chars().any(|ch| ch.is_control()) { + return Err(ArchiveSafetyError::UnsafePath); + } + if path.split('/').any(is_reserved_windows_name) { + return Err(ArchiveSafetyError::UnsafePath); + } + Ok(()) +} + +fn is_reserved_windows_name(part: &str) -> bool { + let stem = part + .split('.') + .next() + .unwrap_or(part) + .trim_end_matches(|character| character == ' ' || character == '.') + .to_ascii_uppercase(); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || ((stem.starts_with("COM") || stem.starts_with("LPT")) + && stem[3..].parse::().is_ok_and(|n| (1..=9).contains(&n))) +} + +pub fn check_limits( + limits: ArchiveLimits, + usage: ArchiveUsage, + compressed_bytes: u64, + free_space_bytes: Option, +) -> Result<(), ArchiveSafetyError> { + if usage.entries > limits.max_entries { + return Err(ArchiveSafetyError::EntryCount); + } + if usage.expanded_bytes > limits.max_expanded_bytes { + return Err(ArchiveSafetyError::ExpandedBytes); + } + if usage.max_entry_bytes > limits.max_entry_bytes { + return Err(ArchiveSafetyError::EntryBytes); + } + if usage.nesting_depth > limits.max_nesting_depth { + return Err(ArchiveSafetyError::NestingDepth); + } + if usage.expanded_bytes > 0 && compressed_bytes == 0 { + return Err(ArchiveSafetyError::CompressionRatio); + } + if compressed_bytes > 0 { + let max_expanded = compressed_bytes + .checked_mul(limits.max_compression_ratio) + .unwrap_or(u64::MAX); + if usage.expanded_bytes > max_expanded { + return Err(ArchiveSafetyError::CompressionRatio); + } + } + if free_space_bytes.is_some_and(|free| free < limits.free_space_reserve_bytes) { + return Err(ArchiveSafetyError::FreeSpaceReserve); + } + if usage.active_seconds > limits.max_active_seconds { + return Err(ArchiveSafetyError::ActiveTime); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn limits() -> ArchiveLimits { + ArchiveLimits { + max_entries: 2, + max_expanded_bytes: 100, + max_entry_bytes: 80, + max_nesting_depth: 2, + max_compression_ratio: 10, + free_space_reserve_bytes: 20, + max_active_seconds: 60, + } + } + + #[test] + fn rejects_unsafe_member_paths() { + for path in [ + "../x", + "/tmp/x", + "C:/x", + "a\\b", + "CON.txt", + "NUL ", + "CON .txt", + "a/\u{7f}b", + ] { + assert_eq!( + validate_member_path(path), + Err(ArchiveSafetyError::UnsafePath) + ); + } + assert!(validate_member_path("folder/file.txt").is_ok()); + } + + #[test] + fn enforces_task_wide_limits() { + let mut usage = ArchiveUsage { + entries: 3, + ..Default::default() + }; + assert_eq!( + check_limits(limits(), usage, 1, Some(100)), + Err(ArchiveSafetyError::EntryCount) + ); + usage.entries = 1; + usage.expanded_bytes = 101; + assert_eq!( + check_limits(limits(), usage, 1, Some(100)), + Err(ArchiveSafetyError::ExpandedBytes) + ); + } + + #[test] + fn requires_confirmation_for_higher_finite_limits() { + let defaults = limits(); + let mut requested = defaults; + requested.max_entries += 1; + assert_eq!( + validate_limits_override(defaults, requested, false), + Err(ArchiveSafetyError::OverrideConfirmation) + ); + assert_eq!( + validate_limits_override(defaults, requested, true), + Ok(requested) + ); + requested.max_entries = defaults.max_entries * 5; + assert_eq!( + validate_limits_override(defaults, requested, true), + Err(ArchiveSafetyError::HardCeiling) + ); + } + + #[test] + fn requires_confirmation_to_reduce_free_space_reserve() { + let defaults = limits(); + let mut requested = defaults; + requested.free_space_reserve_bytes = 0; + + assert_eq!( + validate_limits_override(defaults, requested, false), + Err(ArchiveSafetyError::OverrideConfirmation) + ); + assert_eq!( + validate_limits_override(defaults, requested, true), + Ok(requested) + ); + } + + #[test] + fn rejects_nesting_depth_values_that_do_not_fit_the_engine_type() { + let defaults = limits(); + let value = serde_json::json!({ "maxNestingDepth": u64::from(u32::MAX) + 1 }); + assert_eq!( + validate_limits_override_value(defaults, &value, true), + Err(ArchiveSafetyError::HardCeiling) + ); + } + + #[test] + fn compression_ratio_uses_exact_overflow_safe_comparison() { + let mut configured = limits(); + configured.max_expanded_bytes = u64::MAX; + let mut usage = ArchiveUsage { + expanded_bytes: 101, + ..Default::default() + }; + assert_eq!( + check_limits(configured, usage, 10, Some(100)), + Err(ArchiveSafetyError::CompressionRatio) + ); + usage.expanded_bytes = 1; + assert_eq!( + check_limits(configured, usage, 0, Some(100)), + Err(ArchiveSafetyError::CompressionRatio) + ); + usage.expanded_bytes = 0; + assert!(check_limits(configured, usage, 0, Some(100)).is_ok()); + } +} diff --git a/src-tauri/risuko-engine/src/engine/error_code.rs b/src-tauri/risuko-engine/src/engine/error_code.rs index 837324e1..91655faf 100644 --- a/src-tauri/risuko-engine/src/engine/error_code.rs +++ b/src-tauri/risuko-engine/src/engine/error_code.rs @@ -67,6 +67,11 @@ impl ErrorCode { pub const MEDIA_TOOL_NOT_FOUND: Self = Self(540); pub const MEDIA_AUTH_REQUIRED: Self = Self(541); pub const MEDIA_FORMAT_UNAVAILABLE: Self = Self(542); + pub const USENET_AUTH_FAILED: Self = Self(550); + pub const USENET_ARTICLE_UNAVAILABLE: Self = Self(551); + pub const USENET_ARCHIVE_UNSAFE: Self = Self(552); + pub const USENET_ARCHIVE_LIMIT: Self = Self(553); + pub const USENET_REPAIR_FAILED: Self = Self(554); // -- Internal -- pub const ENGINE_NOT_RUNNING: Self = Self(900); @@ -218,6 +223,48 @@ pub fn classify_error(msg: &str, protocol: &str) -> ErrorCode { return ErrorCode::M3U8_SEGMENT_FAILED; } } + "usenet" => { + if lower.contains("auth") || lower.contains("credential") { + return ErrorCode::USENET_AUTH_FAILED; + } + if lower.contains("archive safety:") { + if lower.contains("unsafepath") { + return ErrorCode::USENET_ARCHIVE_UNSAFE; + } + if [ + "entrycount", + "expandedbytes", + "entrybytes", + "nestingdepth", + "compressionratio", + "freespacereserve", + "activetime", + ] + .iter() + .any(|variant| lower.contains(variant)) + { + return ErrorCode::USENET_ARCHIVE_LIMIT; + } + } + if lower.contains("unsafe archive") + || lower.contains("unsafe path") + || lower.contains("unsafe par2") + { + return ErrorCode::USENET_ARCHIVE_UNSAFE; + } + if lower.contains("archive limit") + || lower.contains("archive bomb") + || lower.contains("par2 safety limit") + { + return ErrorCode::USENET_ARCHIVE_LIMIT; + } + if lower.contains("par2") || lower.contains("repair") { + return ErrorCode::USENET_REPAIR_FAILED; + } + if lower.contains("article") || lower.contains("message id") { + return ErrorCode::USENET_ARTICLE_UNAVAILABLE; + } + } "ftp" | "sftp" => { if lower.contains("login") || contains_status(&lower, "530") { return ErrorCode::FTP_LOGIN_FAILED; @@ -432,4 +479,34 @@ mod tests { ErrorCode::MEDIA_AUTH_REQUIRED ); } + + #[test] + fn classifies_par2_safety_failures_as_usenet_archive_failures() { + assert_eq!( + classify_error("PAR2 safety limit: too many recovery blocks", "usenet"), + ErrorCode::USENET_ARCHIVE_LIMIT + ); + assert_eq!( + classify_error("unsafe PAR2 filename: ../escape.bin", "usenet"), + ErrorCode::USENET_ARCHIVE_UNSAFE + ); + } + + #[test] + fn classifies_formatted_archive_pipeline_safety_failures() { + use crate::engine::archive_pipeline::ArchivePipelineError; + use crate::engine::archive_safety::ArchiveSafetyError; + + let unsafe_path = ArchivePipelineError::Safety(ArchiveSafetyError::UnsafePath).to_string(); + assert_eq!( + classify_error(&unsafe_path, "usenet"), + ErrorCode::USENET_ARCHIVE_UNSAFE + ); + + let expanded = ArchivePipelineError::Safety(ArchiveSafetyError::ExpandedBytes).to_string(); + assert_eq!( + classify_error(&expanded, "usenet"), + ErrorCode::USENET_ARCHIVE_LIMIT + ); + } } diff --git a/src-tauri/risuko-engine/src/engine/http.rs b/src-tauri/risuko-engine/src/engine/http.rs index 03c26c33..8a83361b 100644 --- a/src-tauri/risuko-engine/src/engine/http.rs +++ b/src-tauri/risuko-engine/src/engine/http.rs @@ -267,6 +267,8 @@ impl PieceQueue { /// aria2 default: 60s connect timeout when not configured const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 60; +/// Default maximum idle time between chunks of an NZB response body. +const DEFAULT_NZB_BODY_TIMEOUT_SECS: u64 = 30; /// aria2 default for `--lowest-speed-limit-timeout`: 30s of below-threshold /// transfer before the worker is considered stalled const DEFAULT_LOWEST_SPEED_TIMEOUT_SECS: u64 = 30; @@ -489,6 +491,49 @@ pub async fn fetch_for_metalink_probe( .map_err(|_| "metalink probe timed out".to_string())? } +/// Fetch an NZB URL using the same HTTP client and request headers as a +/// regular HTTP task. The response is consumed incrementally so chunked +/// responses cannot bypass the payload cap. +pub async fn fetch_for_nzb(uri: &str, options: &Map) -> Result, String> { + const CAP: u64 = 16 * 1024 * 1024; + let client = build_client(options, true, load_cookie_jar(options))?; + let mut headers = build_headers(options); + apply_netrc_auth(&mut headers, uri, options); + let header_timeout = + parse_duration_secs_option(options.get("connect-timeout"), DEFAULT_CONNECT_TIMEOUT_SECS); + let response = tokio::time::timeout(header_timeout, client.get(uri).headers(headers).send()) + .await + .map_err(|_| "NZB URL response timed out".to_string())? + .map_err(|e| format!("NZB URL fetch failed: {e}"))? + .error_for_status() + .map_err(|e| format!("NZB URL returned an error: {e}"))?; + + if response.content_length().is_some_and(|length| length > CAP) { + return Err("NZB URL payload too large".to_string()); + } + + let mut bytes = Vec::with_capacity(response.content_length().unwrap_or(0).min(CAP) as usize); + let mut stream = response.bytes_stream(); + let body_timeout = parse_duration_secs_option( + options.get("nzb-body-timeout"), + DEFAULT_NZB_BODY_TIMEOUT_SECS, + ); + let mut total = 0u64; + loop { + let item = tokio::time::timeout(body_timeout, stream.next()) + .await + .map_err(|_| "NZB response body timed out".to_string())?; + let Some(item) = item else { break }; + let chunk = item.map_err(|e| format!("NZB URL body read failed: {e}"))?; + total = total.saturating_add(chunk.len() as u64); + if total > CAP { + return Err("NZB URL payload too large".to_string()); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + /// Build a HTTP Client with common settings applied from options /// /// `decompress` is off for range requests, which must receive raw bytes at @@ -2782,8 +2827,7 @@ fn set_file_mtime(path: &Path, time: std::time::SystemTime) -> std::io::Result<( }; #[cfg(not(windows))] - let file = fs::File::open(path) - .or_else(|_| fs::OpenOptions::new().write(true).open(path))?; + let file = fs::File::open(path).or_else(|_| fs::OpenOptions::new().write(true).open(path))?; file.set_times(times) } diff --git a/src-tauri/risuko-engine/src/engine/manager.rs b/src-tauri/risuko-engine/src/engine/manager.rs index afada79c..ef624043 100644 --- a/src-tauri/risuko-engine/src/engine/manager.rs +++ b/src-tauri/risuko-engine/src/engine/manager.rs @@ -1,6 +1,6 @@ use serde_json::{Map, Value}; use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -18,10 +18,13 @@ use super::session::SessionManager; use super::speed_limiter::{parse_speed_limit, SpeedLimiter}; use super::task::{ generate_gid, ChunkProgress, DownloadFile, DownloadTask, FileUri, PeerInfo, TaskKind, - TaskStatus, + TaskStatus, UsenetRepairFailure, UsenetTaskData, UsenetTaskFile, UsenetTaskOptions, + UsenetTaskSegment, }; use super::torrent::{self, TorrentEngine}; use super::upload::UploadFileSnapshot; +use super::usenet::UsenetProviderProfile; +use super::usenet_transport::{ProviderConnectionCapacityRegistry, ProviderConnectionLease}; use std::collections::HashSet; const MAGNET_METADATA_ATTEMPT_TIMEOUT_SECS: u64 = 60; @@ -87,10 +90,6 @@ impl Counters { } } -/// Per-chunk progress with a fair-share baseline denominator. With -/// work-stealing, each counter holds total bytes downloaded by worker i -/// across all pieces it pulled — not a fixed slice — so clamp so percent -/// never exceeds 100% fn chunk_progress(chunks: &[Arc], total: u64) -> Vec { let split_count = chunks.len() as u64; let chunk_size = total / split_count; @@ -112,13 +111,6 @@ fn chunk_progress(chunks: &[Arc], total: u64) -> Vec { .collect() } -/// Epoch-checked completion bookkeeping shared by every protocol worker: -/// sync the task record from the counters, build the single-file vec and -/// emit DownloadComplete on success, map cancellation to Paused, mark -/// anything else as Error, then drop the active entry if it's still this -/// worker's. `on_found` runs for both outcomes (http's chunk snapshot), -/// `on_ok` applies protocol extras and returns the file completed-length, -/// `on_err` classifies the error (http also evicts stale cookies) #[allow(clippy::too_many_arguments)] async fn finish_task( tasks: &Arc, @@ -155,21 +147,23 @@ async fn finish_task( path.display() ); task.status = TaskStatus::Complete; - task.files = vec![DownloadFile { - index: "1".to_string(), - path: path.to_string_lossy().to_string(), - length: task.total_length.to_string(), - completed_length: file_completed.to_string(), - selected: "true".to_string(), - uris: task - .uris - .iter() - .map(|u| FileUri { - uri: u.clone(), - status: "used".to_string(), - }) - .collect(), - }]; + if task.kind != TaskKind::Usenet { + task.files = vec![DownloadFile { + index: "1".to_string(), + path: path.to_string_lossy().to_string(), + length: task.total_length.to_string(), + completed_length: file_completed.to_string(), + selected: "true".to_string(), + uris: task + .uris + .iter() + .map(|u| FileUri { + uri: u.clone(), + status: "used".to_string(), + }) + .collect(), + }]; + } events.send(EngineEvent::DownloadComplete { gid: gid.to_string(), }); @@ -202,6 +196,37 @@ async fn finish_task( } } +fn finish_usenet_failure( + task: &mut DownloadTask, + error: &str, + repair_failure: Option, +) -> super::error_code::ErrorCode { + task.usenet_stage = Some("error".to_string()); + task.usenet_repair_failure = repair_failure; + classify_error(error, "usenet") +} + +fn usenet_stage_rank(stage: &str) -> u8 { + match stage { + "connecting" => 0, + "fetching" => 1, + "assembling" => 2, + "repairing" | "verifying" => 3, + "complete" => 4, + "error" => 5, + unknown => { + tracing::warn!(stage = unknown, "unrecognised Usenet progress stage"); + 0 + } + } +} + +fn should_update_usenet_stage(current: Option<&str>, next: &str) -> bool { + current + .map(|current| usenet_stage_rank(next) >= usenet_stage_rank(current)) + .unwrap_or(true) +} + async fn metalink_finish( tasks: &Arc, active: &Arc>>, @@ -394,6 +419,7 @@ pub struct TaskManager { torrent_engine: Arc>>, global_speed_limiter: Arc, cookie_store: Arc, + usenet_connection_capacity: Arc, } /// Extract filename hint from the first HTTP URI by parsing URL path @@ -532,6 +558,7 @@ impl TaskManager { torrent_engine: Arc::new(RwLock::new(torrent_engine)), global_speed_limiter, cookie_store: Arc::new(CookieStore::new(config_dir)), + usenet_connection_capacity: Arc::new(ProviderConnectionCapacityRegistry::default()), }; if manager.restore_torrent_mappings().await { @@ -904,6 +931,156 @@ impl TaskManager { .await } + pub async fn add_nzb_task( + &self, + nzb: Vec, + options: Map, + ) -> Result { + let document = super::usenet::parse(&nzb)?; + let filename_hint = document + .title + .clone() + .or_else(|| document.files.first().map(|file| file.name.clone())) + .unwrap_or_else(|| "usenet".to_string()); + let (dir, tag) = self + .resolve_routing_for_task(&options, &filename_hint) + .await; + let files: Vec = document + .files + .iter() + .enumerate() + .map(|(index, file)| { + let length = file + .segments + .iter() + .try_fold(0u64, |total, segment| total.checked_add(segment.bytes)) + .ok_or_else(|| format!("NZB file {:?} byte count overflowed", file.name))?; + Ok(DownloadFile { + index: (index + 1).to_string(), + path: Path::new(&dir) + .join(super::util::safe_filename(&file.name, "download")) + .to_string_lossy() + .to_string(), + length: length.to_string(), + completed_length: "0".to_string(), + selected: "true".to_string(), + uris: file + .segments + .iter() + .map(|segment| FileUri { + uri: format!("nntp://{}", segment.message_id), + status: "waiting".to_string(), + }) + .collect(), + }) + }) + .collect::>()?; + let gid = generate_gid(); + let mut options = options; + for key in [ + "username", + "password", + "usenet-username", + "usenet-password", + "archive-password", + "usenet-archive-password", + ] { + options.remove(key); + } + for key in ["usenet-profiles", "usenetProfiles"] { + if let Some(value) = options.get_mut(key) { + let profiles: Vec = serde_json::from_value(value.clone()) + .map_err(|error| format!("Invalid Usenet provider profiles: {error}"))?; + *value = serde_json::to_value(profiles) + .map_err(|error| format!("Invalid Usenet provider profiles: {error}"))?; + } + } + if let Some(value) = options.get("usenet-archive-limits") { + let defaults = if cfg!(target_os = "android") { + super::archive_safety::ArchiveLimits::android_defaults() + } else { + super::archive_safety::ArchiveLimits::desktop_defaults() + }; + let confirmed = options + .get("usenet-archive-limit-override-confirmed") + .and_then(Value::as_bool) + .unwrap_or(false); + super::archive_safety::validate_limits_override_value(defaults, value, confirmed) + .map_err(|error| format!("Invalid Usenet archive limits: {error:?}"))?; + } + options.insert( + "usenet-nzb-bytes".to_string(), + Value::Number((nzb.len() as u64).into()), + ); + options.insert( + "usenet-title".to_string(), + document + .title + .clone() + .map(Value::String) + .unwrap_or(Value::Null), + ); + options.insert( + "usenet-segment-count".to_string(), + Value::Number( + document + .files + .iter() + .map(|file| file.segments.len() as u64) + .sum::() + .into(), + ), + ); + let usenet_options = UsenetTaskOptions { + profile_id: options + .get("usenet-profile-id") + .and_then(Value::as_str) + .map(str::to_string), + cleanup_mode: options + .get("usenet-cleanup-mode") + .and_then(Value::as_str) + .map(str::to_string), + archive_limits: options.get("usenet-archive-limits").cloned(), + archive_limit_override_confirmed: options + .get("usenet-archive-limit-override-confirmed") + .and_then(Value::as_bool) + .unwrap_or(false), + }; + let metadata = UsenetTaskData { + options: usenet_options, + files: document + .files + .iter() + .map(|file| UsenetTaskFile { + name: file.name.clone(), + subject: file.subject.clone(), + groups: file.groups.clone(), + segments: file + .segments + .iter() + .map(|segment| UsenetTaskSegment { + number: segment.number, + bytes: segment.bytes, + message_id: segment.message_id.clone(), + }) + .collect(), + }) + .collect(), + }; + let task = DownloadTask::new_usenet(gid, dir, tag, document.title, options, files) + .with_usenet_data(metadata); + self.enqueue(task).await + } + + pub fn try_acquire_usenet_profile_connection( + &self, + profile: &super::usenet::UsenetProviderProfile, + ) -> Result, String> { + self.usenet_connection_capacity + .try_acquire(profile) + .map_err(|error| error.to_string()) + } + pub async fn add_magnet_task( &self, magnet_uri: &str, @@ -1394,6 +1571,132 @@ impl TaskManager { }); } + fn spawn_usenet_download(&self, task: &DownloadTask, merged_options: Map) { + let gid = task.gid.clone(); + let task_snapshot = task.clone(); + let tasks = self.tasks.clone(); + let active = self.active_downloads.clone(); + let events = self.events.clone(); + let connection_capacity = self.usenet_connection_capacity.clone(); + let output_paths = Arc::new(parking_lot::Mutex::new(HashMap::::new())); + let output_paths_for_worker = output_paths.clone(); + let counters = Counters::new(task.total_length, 0); + let worker_epoch = next_worker_epoch(); + tokio::spawn(async move { + active.write().await.insert( + gid.clone(), + counters.to_active( + worker_epoch, + Vec::new(), + Arc::new(parking_lot::Mutex::new(None)), + ), + ); + let result = super::usenet_worker::run_usenet_download_with_resolver_and_capacity( + &task_snapshot, + &merged_options, + counters.completed.clone(), + counters.total.clone(), + counters.cancel_token.clone(), + super::usenet_credential_resolver().await, + connection_capacity, + Some(Arc::new({ + let tasks = tasks.clone(); + let gid = gid.clone(); + move |stage: &str| { + let tasks = tasks.clone(); + let gid = gid.clone(); + let stage = stage.to_string(); + tokio::spawn(async move { + let mut tasks = tasks.write().await; + if let Some(task) = tasks.iter_mut().find(|task| task.gid == gid) { + if task.status == TaskStatus::Active + && should_update_usenet_stage( + task.usenet_stage.as_deref(), + &stage, + ) + { + task.usenet_stage = Some(stage); + } + } + }); + } + })), + ) + .await; + let repair_failure = result + .as_ref() + .err() + .and_then(|error| error.repair_failure().cloned()); + let result = result + .map(|(path, outputs)| { + *output_paths_for_worker.lock() = outputs.into_iter().collect(); + path + }) + .map_err(|error| error.to_string()); + finish_task( + &tasks, + &active, + &events, + &gid, + worker_epoch, + "usenet", + &counters, + result, + |_| {}, + |task, path| { + task.usenet_repair_failure = None; + let output_paths = output_paths.lock().clone(); + if let Some(metadata) = task.usenet.as_ref() { + task.usenet_stage = Some("complete".to_string()); + task.files = metadata + .files + .iter() + .enumerate() + .filter_map(|(index, file)| { + let mapped = output_paths.get(&index).cloned(); + if mapped.as_ref().is_some_and(|path| !path.exists()) { + return None; + } + let path = mapped.unwrap_or_else(|| { + Path::new(&task.dir) + .join(super::util::safe_filename(&file.name, "download")) + }); + Some(DownloadFile { + index: (index + 1).to_string(), + path: path.to_string_lossy().to_string(), + length: file + .segments + .iter() + .map(|segment| segment.bytes) + .sum::() + .to_string(), + completed_length: file + .segments + .iter() + .map(|segment| segment.bytes) + .sum::() + .to_string(), + selected: "true".to_string(), + uris: Vec::new(), + }) + }) + .collect(); + } + if task.files.is_empty() { + path.metadata().map(|metadata| metadata.len()).unwrap_or(0) + } else { + task.files + .iter() + .filter_map(|file| file.completed_length.parse::().ok()) + .sum() + } + }, + |task, error| finish_usenet_failure(task, error, repair_failure), + ) + .await; + }); + } + /// Start download workers for waiting tasks up to max concurrent limit async fn try_start_next(&self) { let (max_concurrent, options_snapshot) = { @@ -1458,6 +1761,13 @@ impl TaskManager { self.spawn_metalink_download(task, merged); self.send_download_start(&task.gid); started += 1; + } else if task.kind == TaskKind::Usenet && task.usenet.is_some() { + task.status = TaskStatus::Active; + task.usenet_stage = Some("connecting".to_string()); + let merged = options_snapshot.merge_task_options(&task.options); + self.spawn_usenet_download(task, merged); + self.send_download_start(&task.gid); + started += 1; } else if matches!( task.kind, TaskKind::Adc | TaskKind::Gnutella | TaskKind::G2 | TaskKind::Gift @@ -2555,6 +2865,7 @@ impl TaskManager { } task.error_code = None; task.error_message = None; + task.usenet_repair_failure = None; is_torrent = task.kind == TaskKind::Torrent; if is_torrent { task.status = TaskStatus::Active; @@ -3835,9 +4146,46 @@ mod tests { torrent_engine: Arc::new(RwLock::new(None)), global_speed_limiter, cookie_store, + usenet_connection_capacity: Arc::new(ProviderConnectionCapacityRegistry::default()), } } + #[test] + fn usenet_failure_records_a_structured_par2_summary() { + let mut task = DownloadTask::new_usenet( + "ugid".into(), + "/dl".into(), + None, + None, + Map::new(), + Vec::new(), + ); + let repair_failure = UsenetRepairFailure { + needed_blocks: 184, + available_blocks: 62, + partials_retained: true, + }; + + let error_code = finish_usenet_failure( + &mut task, + "PAR2 recovery is insufficient: need 184 blocks, have 62", + Some(repair_failure.clone()), + ); + + assert_eq!(error_code.to_string(), "554"); + assert_eq!(task.usenet_stage.as_deref(), Some("error")); + assert_eq!(task.usenet_repair_failure, Some(repair_failure)); + } + + #[test] + fn usenet_stage_updates_cannot_regress() { + assert!(should_update_usenet_stage(Some("assembling"), "repairing")); + assert!(!should_update_usenet_stage(Some("verifying"), "assembling")); + assert!(!should_update_usenet_stage(Some("complete"), "fetching")); + assert!(!should_update_usenet_stage(Some("error"), "complete")); + assert!(should_update_usenet_stage(None, "fetching")); + } + #[tokio::test] async fn save_session_skips_until_a_write_bumps_rev() { let mgr = make_test_manager(vec![make_task("g1", TaskStatus::Complete)]); diff --git a/src-tauri/risuko-engine/src/engine/mod.rs b/src-tauri/risuko-engine/src/engine/mod.rs index e953b077..e3d6f8cd 100644 --- a/src-tauri/risuko-engine/src/engine/mod.rs +++ b/src-tauri/risuko-engine/src/engine/mod.rs @@ -24,10 +24,17 @@ pub mod task; pub mod torrent; pub mod upload; pub mod uri_selector; +pub mod usenet; +pub mod usenet_par2; +pub mod usenet_pipeline; +pub mod usenet_transport; +pub mod usenet_worker; pub(crate) mod util; // Legacy P2P / IPC protocol stacks pub mod adc; +pub mod archive_pipeline; +pub mod archive_safety; pub mod g2; pub mod gift; pub mod gnutella; @@ -40,8 +47,6 @@ pub use session::SESSION_FILENAME; /// Suffix for per-chunk resume metadata sidecar file pub const CHUNK_META_SUFFIX: &str = ".chunks"; -/// Startup-only config keys (changes require an engine restart to take effect). -/// Mirrors the list maintained on the frontend in `src/shared/configKeys.ts` pub const STARTUP_ONLY_KEYS: &[&str] = &[ "rpc-listen-port", "rpc-secret", @@ -58,9 +63,11 @@ pub const STARTUP_ONLY_KEYS: &[&str] = &[ "bt-listen-v6", ]; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::Mutex; +use tokio::sync::RwLock; use crate::config::ConfigManager; use crate::traits::EventSink; @@ -74,12 +81,104 @@ use self::upload::UploadSinkManager; static ENGINE_INSTANCE: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(None)); -/// Set when the engine successfully starts; cleared on stop. Read by health checks -/// to derive uptime without coupling to TaskManager internals +static USENET_CREDENTIAL_RESOLVER: std::sync::LazyLock< + RwLock>>, +> = std::sync::LazyLock::new(|| RwLock::new(None)); + +pub async fn set_usenet_credential_resolver( + resolver: std::sync::Arc, +) { + *USENET_CREDENTIAL_RESOLVER.write().await = Some(resolver); +} + +/// Install a file-backed resolver for hosts that own the engine lifecycle +/// (standalone/headless and NAPI). Unlike `ensure_*`, this refreshes the path +/// on every host start so a reused process cannot retain an old config dir. +pub async fn set_file_usenet_credential_resolver(config_dir: impl Into) { + set_usenet_credential_resolver(Arc::new(FileUsenetCredentialResolver::new(config_dir))).await; +} + +/// Install the file-backed resolver only when the host has not supplied a +/// stronger resolver (for example, the Tauri OS-keychain resolver). +pub async fn ensure_file_usenet_credential_resolver(config_dir: impl Into) { + let mut guard = USENET_CREDENTIAL_RESOLVER.write().await; + if guard.is_none() { + *guard = Some(Arc::new(FileUsenetCredentialResolver::new(config_dir))); + } +} + +/// Location of the durable plaintext fallback used when an OS keychain is +/// unavailable. The file is deliberately separate from user.json so secrets +/// do not cross the normal renderer configuration boundary. +pub fn usenet_credential_fallback_path(config_dir: &Path) -> PathBuf { + config_dir.join("usenet-credentials.json") +} + +/// File-backed credential resolver for standalone and NAPI hosts. Tauri uses +/// the same file only as a fallback behind its keychain resolver. +pub struct FileUsenetCredentialResolver { + path: PathBuf, +} + +impl FileUsenetCredentialResolver { + pub fn new(config_dir: impl Into) -> Self { + Self { + path: usenet_credential_fallback_path(&config_dir.into()), + } + } +} + +#[async_trait::async_trait] +impl usenet::UsenetCredentialResolver for FileUsenetCredentialResolver { + async fn resolve(&self, profile_id: &str) -> Result, String> { + let path = self.path.clone(); + let text = match tokio::task::spawn_blocking(move || std::fs::read_to_string(path)) + .await + .map_err(|error| format!("Failed to read Usenet credentials: {error}"))? + { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("Failed to read Usenet credentials: {error}")), + }; + let root: serde_json::Value = serde_json::from_str(&text) + .map_err(|error| format!("Failed to parse Usenet credentials: {error}"))?; + let Some(entry) = root.get(profile_id).and_then(serde_json::Value::as_object) else { + return Ok(None); + }; + let username = entry + .get("username") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let password = entry + .get("password") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if username.is_none() && password.is_none() { + Ok(None) + } else { + Ok(Some(usenet::UsenetCredentials { username, password })) + } + } +} + +pub async fn usenet_credential_resolver() -> std::sync::Arc { + // Host entry points call `ensure_file_usenet_credential_resolver` (or + // install their own resolver) before creating a manager. Keep the + // anonymous fallback for direct library users that intentionally construct + // a manager without a host credential store. + USENET_CREDENTIAL_RESOLVER + .read() + .await + .clone() + .unwrap_or_else(|| { + std::sync::Arc::new(crate::engine::usenet_worker::AnonymousCredentialResolver) + }) +} + static ENGINE_STARTED_AT: std::sync::Mutex> = std::sync::Mutex::new(None); -/// Snapshot of the values of `STARTUP_ONLY_KEYS` at the moment the engine last -/// started. Health checks compare this against the live config to flag drift static STARTUP_SNAPSHOT: std::sync::Mutex< Option>, > = std::sync::Mutex::new(None); @@ -156,6 +255,7 @@ pub async fn start_engine( } let config_dir = config.config_dir().to_path_buf(); + ensure_file_usenet_credential_resolver(config_dir.clone()).await; let system = config.get_system_config(); let user = config.get_user_config(); let options = EngineOptions::from_config(system, user); @@ -381,3 +481,31 @@ pub async fn get_manager() -> Option> { let guard = ENGINE_INSTANCE.lock().await; guard.as_ref().map(|i| i.manager.clone()) } + +#[cfg(test)] +mod credential_tests { + use super::*; + use crate::engine::usenet::UsenetCredentialResolver; + use serde_json::json; + use tempfile::TempDir; + + #[tokio::test] + async fn file_resolver_reads_durable_credentials() { + let dir = TempDir::new().unwrap(); + let path = usenet_credential_fallback_path(dir.path()); + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "primary": { "username": "alice", "password": "secret" } + })) + .unwrap(), + ) + .unwrap(); + + let resolver = FileUsenetCredentialResolver::new(dir.path()); + let credentials = resolver.resolve("primary").await.unwrap().unwrap(); + assert_eq!(credentials.username.as_deref(), Some("alice")); + assert_eq!(credentials.password.as_deref(), Some("secret")); + assert!(resolver.resolve("missing").await.unwrap().is_none()); + } +} diff --git a/src-tauri/risuko-engine/src/engine/options.rs b/src-tauri/risuko-engine/src/engine/options.rs index c5a565cf..db509142 100644 --- a/src-tauri/risuko-engine/src/engine/options.rs +++ b/src-tauri/risuko-engine/src/engine/options.rs @@ -55,6 +55,9 @@ impl EngineOptions { "task-routing-rules", "file-category-dirs", "max-worker-retries", + "usenet-profiles", + "usenet-archive-limits", + "usenet-cleanup-mode", ] { if let Some(v) = user.get(key) { global.insert(key.into(), v.clone()); diff --git a/src-tauri/risuko-engine/src/engine/rpc.rs b/src-tauri/risuko-engine/src/engine/rpc.rs index 44556a0d..87f3b555 100644 --- a/src-tauri/risuko-engine/src/engine/rpc.rs +++ b/src-tauri/risuko-engine/src/engine/rpc.rs @@ -627,6 +627,27 @@ fn dispatch_method<'a>( Ok(Value::String(gid)) } + "risuko.addNzb" => { + let nzb_b64 = params + .first() + .and_then(|v| v.as_str()) + .ok_or_else(|| RpcError::from("NZB data required".to_string()))?; + let nzb_data = base64::engine::general_purpose::STANDARD + .decode(nzb_b64) + .map_err(|e| RpcError::from(format!("Invalid base64: {e}")))?; + let options = params + .get(1) + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + let gid = state + .manager + .add_nzb_task(nzb_data, options) + .await + .map_err(RpcError::from)?; + Ok(Value::String(gid)) + } + "risuko.addEd2k" => { let uri = params .first() @@ -931,6 +952,7 @@ fn list_methods() -> Value { "addUri", "addMedia", "addTorrent", + "addNzb", "addEd2k", "remove", "forceRemove", @@ -1282,4 +1304,14 @@ mod tests { let keys = get_keys(¶ms, 1); assert!(keys.is_empty()); } + + #[test] + fn list_methods_advertises_nzb_endpoint() { + let methods = list_methods(); + assert!(methods + .as_array() + .unwrap() + .iter() + .any(|method| method.as_str() == Some("risuko.addNzb"))); + } } diff --git a/src-tauri/risuko-engine/src/engine/task.rs b/src-tauri/risuko-engine/src/engine/task.rs index e5c8004b..15afeaaf 100644 --- a/src-tauri/risuko-engine/src/engine/task.rs +++ b/src-tauri/risuko-engine/src/engine/task.rs @@ -50,12 +50,61 @@ pub enum TaskKind { M3u8, Ftp, Metalink, + Usenet, Adc, Gnutella, G2, Gift, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsenetTaskOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_limits: Option, + #[serde(default)] + pub archive_limit_override_confirmed: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsenetTaskFile { + pub name: String, + pub subject: String, + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub segments: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsenetTaskSegment { + pub number: u32, + pub bytes: u64, + pub message_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsenetTaskData { + pub options: UsenetTaskOptions, + #[serde(default)] + pub files: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsenetRepairFailure { + pub needed_blocks: u32, + pub available_blocks: u32, + pub partials_retained: bool, +} + #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DownloadFile { @@ -114,6 +163,16 @@ pub struct DownloadTask { pub options: Map, #[serde(default)] pub tag: Option, + /// Non-secret NZB manifest metadata and provider profile reference. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usenet: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usenet_stage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usenet_warning: Option, + /// Non-secret details for an insufficient PAR2 recovery set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usenet_repair_failure: Option, // BitTorrent pub info_hash: Option, #[serde(default)] @@ -289,6 +348,33 @@ impl DownloadTask { } } + pub fn new_usenet( + gid: String, + dir: String, + tag: Option, + title: Option, + options: Map, + files: Vec, + ) -> Self { + let out = title.unwrap_or_default(); + Self { + gid, + kind: TaskKind::Usenet, + dir, + out, + files, + options, + tag, + created_at: now_ms(), + ..Default::default() + } + } + + pub fn with_usenet_data(mut self, usenet: UsenetTaskData) -> Self { + self.usenet = Some(usenet); + self + } + pub fn new_ed2k( gid: String, uri: String, @@ -659,6 +745,39 @@ impl DownloadTask { } } + if self.kind == TaskKind::Usenet { + let want_usenet_manifest = keys.is_empty() || keys.iter().any(|key| key == "usenet"); + if want_usenet_manifest { + if let Some(ref usenet) = self.usenet { + m.insert( + "usenet".into(), + serde_json::to_value(usenet).unwrap_or_default(), + ); + } + } + if (keys.is_empty() || keys.iter().any(|key| key == "usenetStage")) + && self.usenet_stage.is_some() + { + let stage = self.usenet_stage.as_ref().expect("checked above"); + m.insert("usenetStage".into(), Value::String(stage.clone())); + } + if (keys.is_empty() || keys.iter().any(|key| key == "usenetWarning")) + && self.usenet_warning.is_some() + { + let warning = self.usenet_warning.as_ref().expect("checked above"); + m.insert("usenetWarning".into(), Value::String(warning.clone())); + } + if (keys.is_empty() || keys.iter().any(|key| key == "usenetRepairFailure")) + && self.usenet_repair_failure.is_some() + { + let repair_failure = self.usenet_repair_failure.as_ref().expect("checked above"); + m.insert( + "usenetRepairFailure".into(), + serde_json::to_value(repair_failure).unwrap_or_default(), + ); + } + } + // Per-chunk progress for multi-thread HTTP downloads if !self.chunk_progress.is_empty() { let chunks: Vec = self @@ -832,6 +951,139 @@ mod tests { assert_eq!(task.uris[0], "ftp://files.example.com/data.csv"); } + #[test] + fn new_usenet_sets_kind_and_file_metadata() { + let files = vec![DownloadFile { + index: "1".into(), + path: "/dl/archive.part01.rar".into(), + length: "42".into(), + completed_length: "0".into(), + selected: "true".into(), + uris: Vec::new(), + }]; + let metadata = UsenetTaskData { + options: UsenetTaskOptions { + profile_id: Some("provider-main".into()), + ..Default::default() + }, + files: vec![UsenetTaskFile { + name: "archive.part01.rar".into(), + subject: "archive.part01.rar yEnc".into(), + groups: vec!["alt.binaries.example".into()], + segments: vec![UsenetTaskSegment { + number: 1, + bytes: 42, + message_id: "".into(), + }], + }], + }; + + let task = DownloadTask::new_usenet( + "ugid".into(), + "/dl".into(), + None, + Some("Release".into()), + Map::new(), + files, + ) + .with_usenet_data(metadata.clone()); + + assert_eq!(task.kind, TaskKind::Usenet); + assert_eq!(task.out, "Release"); + assert_eq!(task.files.len(), 1); + assert_eq!(task.usenet, Some(metadata)); + } + + #[test] + fn usenet_metadata_round_trips_without_credentials() { + let task = DownloadTask::new_usenet( + "ugid".into(), + "/dl".into(), + None, + None, + Map::new(), + Vec::new(), + ) + .with_usenet_data(UsenetTaskData { + options: UsenetTaskOptions { + profile_id: Some("provider-main".into()), + ..Default::default() + }, + files: vec![UsenetTaskFile { + name: "file.bin".into(), + subject: "file.bin yEnc".into(), + groups: vec!["alt.binaries.example".into()], + segments: vec![UsenetTaskSegment { + number: 1, + bytes: 7, + message_id: "".into(), + }], + }], + }); + + let encoded = serde_json::to_string(&task).unwrap(); + assert!(encoded.contains("profileId")); + assert!(encoded.contains("messageId")); + assert!(!encoded.contains("password")); + + let restored: DownloadTask = serde_json::from_str(&encoded).unwrap(); + assert_eq!(restored.kind, TaskKind::Usenet); + assert_eq!(restored.usenet, task.usenet); + } + + #[test] + fn usenet_repair_failure_round_trips_as_non_secret_task_metadata() { + let mut task = DownloadTask::new_usenet( + "ugid".into(), + "/dl".into(), + None, + None, + Map::new(), + Vec::new(), + ); + task.usenet_repair_failure = Some(UsenetRepairFailure { + needed_blocks: 184, + available_blocks: 62, + partials_retained: true, + }); + + let encoded = serde_json::to_string(&task).unwrap(); + let restored: DownloadTask = serde_json::from_str(&encoded).unwrap(); + + assert_eq!(restored.usenet_repair_failure, task.usenet_repair_failure); + } + + #[test] + fn usenet_repair_failure_defaults_when_absent_from_legacy_task_data() { + let mut task = DownloadTask::new_usenet( + "ugid".into(), + "/dl".into(), + None, + None, + Map::new(), + Vec::new(), + ); + task.usenet_repair_failure = Some(UsenetRepairFailure { + needed_blocks: 184, + available_blocks: 62, + partials_retained: true, + }); + let mut encoded = serde_json::to_value(task).unwrap(); + let removed = encoded + .as_object_mut() + .unwrap() + .remove("usenet_repair_failure"); + + assert!( + removed.is_some(), + "repair failure must be serialized with task data" + ); + + let restored: DownloadTask = serde_json::from_value(encoded).unwrap(); + + assert_eq!(restored.usenet_repair_failure, None); + } + // -- to_rpc_status -- #[test] @@ -871,6 +1123,40 @@ mod tests { assert!(obj.contains_key("status")); } + #[test] + fn rpc_status_stage_request_skips_usenet_manifest() { + let mut task = DownloadTask::new_usenet( + "ugid".into(), + "/dl".into(), + None, + None, + Map::new(), + Vec::new(), + ) + .with_usenet_data(UsenetTaskData { + options: UsenetTaskOptions::default(), + files: vec![UsenetTaskFile { + name: "large.nzb.file".into(), + subject: "large.nzb.file".into(), + groups: vec!["alt.binaries.example".into()], + segments: vec![UsenetTaskSegment { + number: 1, + bytes: 1, + message_id: "example".into(), + }], + }], + }); + task.usenet_stage = Some("assembling".into()); + + let status = task.to_rpc_status(&["usenetStage".to_string()]); + let object = status.as_object().unwrap(); + assert_eq!( + object.get("usenetStage"), + Some(&Value::String("assembling".into())) + ); + assert!(!object.contains_key("usenet")); + } + #[test] fn rpc_status_torrent_has_bittorrent_field() { let mut task = DownloadTask::new_torrent("tgid".into(), "/dl".into(), None, Map::new()); @@ -886,6 +1172,34 @@ mod tests { assert_eq!(bt.get("infoHash").unwrap(), "abc123"); } + #[test] + fn rpc_status_emits_usenet_repair_failure_for_usenet_tasks() { + let mut task = DownloadTask::new_usenet( + "ugid".into(), + "/dl".into(), + None, + None, + Map::new(), + Vec::new(), + ); + task.usenet_repair_failure = Some(UsenetRepairFailure { + needed_blocks: 184, + available_blocks: 62, + partials_retained: true, + }); + + let status = task.to_rpc_status(&[]); + + assert_eq!( + status.get("usenetRepairFailure"), + Some(&json!({ + "neededBlocks": 184, + "availableBlocks": 62, + "partialsRetained": true, + })) + ); + } + #[test] fn test_info_hash_v2_and_meta_version_emitted_both_places() { let mut task = DownloadTask::new_torrent("tgid".into(), "/dl".into(), None, Map::new()); diff --git a/src-tauri/risuko-engine/src/engine/usenet.rs b/src-tauri/risuko-engine/src/engine/usenet.rs new file mode 100644 index 00000000..dcc66af4 --- /dev/null +++ b/src-tauri/risuko-engine/src/engine/usenet.rs @@ -0,0 +1,375 @@ +//! NZB parsing and non-secret Usenet task metadata + +use quick_xml::de::from_str; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsenetProviderProfile { + pub id: String, + pub name: String, + pub host: String, + pub port: u16, + pub security_mode: String, + pub enabled: bool, + pub priority: i32, + pub max_connections: u32, + pub allow_plain: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsenetCredentials { + pub username: Option, + pub password: Option, +} + +#[async_trait::async_trait] +pub trait UsenetCredentialResolver: Send + Sync { + async fn resolve(&self, profile_id: &str) -> Result, String>; +} + +pub fn validate_provider_profile(profile: &UsenetProviderProfile) -> Result<(), String> { + if profile.id.trim().is_empty() || profile.host.trim().is_empty() { + return Err("Usenet provider id and host are required".into()); + } + if profile.port == 0 { + return Err("Usenet provider port must be between 1 and 65535".into()); + } + if !matches!( + profile.security_mode.as_str(), + "implicit-tls" | "starttls" | "plain" + ) { + return Err("Unsupported Usenet security mode".into()); + } + if profile.security_mode == "plain" && !profile.allow_plain { + return Err("Plain NNTP requires explicit per-profile opt-in".into()); + } + if profile.max_connections == 0 { + return Err("Usenet provider max connections must be positive".into()); + } + Ok(()) +} + +pub const MAX_NZB_BYTES: usize = 16 * 1024 * 1024; +pub const MAX_NZB_FILES: usize = 100_000; +pub const MAX_SEGMENTS_PER_FILE: usize = 100_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NzbSegment { + pub number: u32, + pub bytes: u64, + pub message_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NzbFile { + pub name: String, + pub subject: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub poster: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub date: Option, + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub segments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NzbDocument { + #[serde(default)] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + pub files: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawNzb { + head: Option, + #[serde(rename = "file", default)] + files: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawHead { + #[serde(rename = "meta", default)] + meta: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawMeta { + #[serde(rename = "@type")] + kind: Option, + #[serde(rename = "$text")] + value: Option, +} + +#[derive(Debug, Deserialize)] +struct RawFile { + #[serde(rename = "@poster", default)] + poster: Option, + #[serde(rename = "@date", default)] + date: Option, + #[serde(rename = "@subject", default)] + subject: String, + groups: Option, + segments: Option, +} + +#[derive(Debug, Deserialize)] +struct RawGroups { + #[serde(rename = "group", default)] + groups: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawSegments { + #[serde(rename = "segment", default)] + segments: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawSegment { + #[serde(rename = "@bytes")] + bytes: u64, + #[serde(rename = "@number")] + number: u32, + #[serde(rename = "$text")] + message_id: Option, +} + +pub fn parse(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err("NZB payload is empty".into()); + } + if bytes.len() > MAX_NZB_BYTES { + return Err(format!("NZB payload exceeds {} bytes", MAX_NZB_BYTES)); + } + let xml = std::str::from_utf8(bytes).map_err(|e| format!("NZB is not UTF-8: {e}"))?; + let raw: RawNzb = from_str(xml).map_err(|e| format!("NZB parse error: {e}"))?; + if raw.files.is_empty() { + return Err("NZB has no files".into()); + } + if raw.files.len() > MAX_NZB_FILES { + return Err(format!("NZB contains more than {MAX_NZB_FILES} files")); + } + + let (title, category) = raw + .head + .map(|head| { + head.meta + .into_iter() + .fold((None, None), |(title, category), meta| { + let kind = meta.kind.unwrap_or_default(); + let value = meta.value.unwrap_or_default().trim().to_string(); + if value.is_empty() { + return (title, category); + } + if (kind.eq_ignore_ascii_case("title") || kind.eq_ignore_ascii_case("name")) + && title.is_none() + { + (Some(value), category) + } else if kind.eq_ignore_ascii_case("category") && category.is_none() { + (title, Some(value)) + } else { + (title, category) + } + }) + }) + .unwrap_or((None, None)); + + let mut files = Vec::with_capacity(raw.files.len()); + for raw_file in raw.files { + let subject = raw_file.subject.trim().to_string(); + let name = subject_filename(&subject) + .ok_or_else(|| "NZB file has no usable filename".to_string())?; + let groups = raw_file + .groups + .map(|groups| { + groups + .groups + .into_iter() + .map(|group| group.trim().to_string()) + .filter(|group| !group.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + if groups.is_empty() { + return Err(format!("NZB file {name:?} has no newsgroup")); + } + let mut segments = raw_file + .segments + .ok_or_else(|| format!("NZB file {name:?} has no segments"))? + .segments + .into_iter() + .map(|segment| { + let message_id = segment + .message_id + .ok_or_else(|| format!("NZB file {name:?} has a segment without a message id"))? + .trim() + .to_string(); + if message_id.is_empty() { + return Err(format!("NZB file {name:?} has an empty message id")); + } + if segment.number == 0 { + return Err(format!("NZB file {name:?} has segment number 0")); + } + if segment.bytes == 0 { + return Err(format!( + "NZB file {name:?} has a segment with no byte count" + )); + } + Ok(NzbSegment { + number: segment.number, + bytes: segment.bytes, + message_id, + }) + }) + .collect::, String>>()?; + if segments.is_empty() { + return Err(format!("NZB file {name:?} has no usable segments")); + } + if segments.len() > MAX_SEGMENTS_PER_FILE { + return Err(format!("NZB file {name:?} has too many segments")); + } + segments.sort_by_key(|segment| segment.number); + if segments + .windows(2) + .any(|window| window[0].number == window[1].number) + { + return Err(format!("NZB file {name:?} has duplicate segment numbers")); + } + files.push(NzbFile { + name, + subject, + poster: raw_file + .poster + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + date: raw_file.date.and_then(|value| value.trim().parse().ok()), + groups, + segments, + }); + } + + Ok(NzbDocument { + title, + category, + files, + }) +} + +fn subject_filename(subject: &str) -> Option { + let mut value = subject.trim(); + if let Some((_, suffix)) = value.rsplit_once(" - ") { + value = suffix.trim(); + } + for marker in [" yEnc", " yEnc) ", " yEnc]"] { + if let Some((prefix, _)) = value.split_once(marker) { + value = prefix.trim(); + } + } + value = value.trim_matches(['"', '\'']); + let value = value + .replace("[1/1]", "") + .replace("[1/", "") + .trim() + .to_string(); + let base = Path::new(&value) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or(value); + let safe = crate::engine::util::safe_filename(&base, ""); + (!safe.is_empty()).then_some(safe) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &[u8] = br#" + + Release NameTVprivate + + alt.binaries.example + <one@example> + +"#; + + #[test] + fn parses_title_groups_and_segments() { + let doc = parse(SAMPLE).unwrap(); + assert_eq!(doc.title.as_deref(), Some("Release Name")); + assert_eq!(doc.category.as_deref(), Some("TV")); + assert_eq!(doc.files[0].name, "Release.part01.rar"); + assert_eq!(doc.files[0].segments[0].message_id, ""); + assert_eq!(doc.files[0].date, Some(1_700_000_000)); + let encoded = serde_json::to_string(&doc).unwrap(); + assert!(!encoded.contains("private")); + } + + #[test] + fn rejects_missing_group_and_duplicate_numbers() { + let missing_group = br#"a"#; + assert!(parse(missing_group).is_err()); + let duplicate = br#"aab"#; + assert!(parse(duplicate).is_err()); + + let missing_id = br#"a"#; + assert!(parse(missing_id).is_err()); + } + + #[test] + fn extracts_quoted_filename_after_a_multipart_subject_prefix() { + assert_eq!( + subject_filename( + r#"reftestnzb 100MB [01/13] - "rar-files.part01.rar" yEnc (1/37) 26214400"#, + ) + .as_deref(), + Some("rar-files.part01.rar") + ); + } + + #[test] + fn rejects_missing_or_zero_segment_byte_counts() { + let missing = br#"aa"#; + assert!(parse(missing).is_err()); + let zero = br#"aa"#; + assert!(parse(zero).is_err()); + } + + #[test] + fn rejects_subjects_without_a_usable_filename() { + let empty = br#"aa"#; + assert!(parse(empty).is_err()); + } + + #[test] + fn plain_transport_requires_explicit_opt_in() { + let mut profile = UsenetProviderProfile { + id: "p".into(), + name: "Provider".into(), + host: "news.example".into(), + port: 119, + security_mode: "plain".into(), + enabled: true, + priority: 0, + max_connections: 4, + allow_plain: false, + deleted_at: None, + updated_at: None, + }; + assert!(validate_provider_profile(&profile).is_err()); + profile.allow_plain = true; + assert!(validate_provider_profile(&profile).is_ok()); + } +} diff --git a/src-tauri/risuko-engine/src/engine/usenet_par2.rs b/src-tauri/risuko-engine/src/engine/usenet_par2.rs new file mode 100644 index 00000000..ef67c651 --- /dev/null +++ b/src-tauri/risuko-engine/src/engine/usenet_par2.rs @@ -0,0 +1,2292 @@ +//! PAR2 verification and repair + +use crate::engine::archive_pipeline::{Par2Outcome, Par2Report}; +use crate::engine::archive_safety::{validate_member_path, ArchiveLimits}; +use md5::{Digest, Md5}; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fs; +use std::io::{self, BufReader, Read, Seek, SeekFrom, Write}; +use std::path::{Component, Path, PathBuf}; +use std::time::{Duration, Instant}; +use tokio_util::sync::CancellationToken; + +const PAR2_MAGIC: &[u8; 8] = b"PAR2\0PKT"; +const PAR2_HEADER_BYTES: u64 = 64; +const PAR2_TYPE_MAIN: &[u8; 16] = b"PAR 2.0\0Main\0\0\0\0"; +const PAR2_TYPE_FILE_DESC: &[u8; 16] = b"PAR 2.0\0FileDesc"; +const PAR2_TYPE_IFSC: &[u8; 16] = b"PAR 2.0\0IFSC\0\0\0\0"; +const PAR2_TYPE_RECOVERY: &[u8; 16] = b"PAR 2.0\0RecvSlic"; +const MAX_PAR2_PACKET_COUNT: u64 = 100_000; +const MAX_PAR2_INPUT_BYTES: u64 = 16 * 1024 * 1024 * 1024; +const MAX_PAR2_FILE_BYTES: u64 = 4 * 1024 * 1024 * 1024; +const MAX_PAR2_SLICE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_PAR2_METADATA_PACKET_BYTES: u64 = 8 * 1024 * 1024; +const MAX_PAR2_SOURCE_BLOCKS: u64 = 65_535; +const MAX_PAR2_SOURCE_FILES: u64 = 65_535; +const MAX_PAR2_RECOVERY_BLOCKS: u32 = 4_096; +const MAX_PAR2_REPAIR_BLOCKS: u32 = 1_024; +const PAR2_REPAIR_MATRIX_BYTES_PER_CELL: u64 = 8; +const PAR2_REPAIR_BATCH_BLOCKS: u64 = 24; +const SPARSE_COPY_BUFFER_BYTES: usize = 128 * 1024; +const VERIFY_HASH_BUFFER_BYTES: u64 = 2 * 1024 * 1024; + +#[derive(Debug, Clone)] +pub struct Par2InputFile { + pub manifest_name: String, + pub source_path: PathBuf, + pub output_path: PathBuf, + pub expected_size: Option, +} + +#[derive(Debug, Clone)] +pub struct Par2RepairRequest { + pub destination: PathBuf, + pub data_files: Vec, + pub parity_files: Vec, + pub required_incomplete_names: BTreeSet, + pub limits: ArchiveLimits, + pub active_started_at: Option, + pub active_elapsed_before_repair: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Par2RepairResult { + pub report: Par2Report, + pub promoted_outputs: Vec, +} + +#[derive(Debug)] +pub enum Par2Error { + MissingParity, + Malformed(String), + UnsafePath(String), + Limits(String), + InsufficientRecovery { needed: u32, available: u32 }, + Cancelled, + Repair(String), + Io(io::Error), +} + +impl std::fmt::Display for Par2Error { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingParity => { + formatter.write_str("PAR2 repair requires a complete index and recovery volume") + } + Self::Malformed(message) => write!(formatter, "malformed PAR2: {message}"), + Self::UnsafePath(path) => write!(formatter, "unsafe PAR2 filename: {path}"), + Self::Limits(message) => write!(formatter, "PAR2 safety limit: {message}"), + Self::InsufficientRecovery { needed, available } => write!( + formatter, + "PAR2 recovery is insufficient: need {needed} blocks, have {available}" + ), + Self::Cancelled => formatter.write_str("Download cancelled"), + Self::Repair(message) => write!(formatter, "PAR2 repair failed: {message}"), + Self::Io(error) => write!(formatter, "PAR2 I/O: {error}"), + } + } +} + +impl std::error::Error for Par2Error {} + +impl From for Par2Error { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +/// Verify a PAR2 set and repair it in a private stage when necessary +pub fn verify_or_repair(request: &Par2RepairRequest) -> Result { + verify_or_repair_with_cancel(request, None) +} + +pub fn verify_or_repair_with_cancel( + request: &Par2RepairRequest, + cancel: Option<&CancellationToken>, +) -> Result { + check_cancel(cancel)?; + check_active_time(request)?; + if request.parity_files.is_empty() { + return Err(Par2Error::MissingParity); + } + fs::create_dir_all(&request.destination)?; + let limits = request.limits; + let data_by_name = validate_input_files(&request.data_files, limits)?; + let parity = validate_parity_files(&request.parity_files)?; + let (file_set, source) = parse_index( + &parity.files, + &data_by_name, + &request.required_incomplete_names, + limits, + )?; + validate_task_budget(request, source, parity.total)?; + validate_verification_resources(&file_set)?; + ensure_staging_space(request, source.bytes, parity.total.bytes)?; + let stage = tempfile::Builder::new() + .prefix(".risuko-par2-") + .tempdir_in(&request.destination) + .map_err(Par2Error::Io)?; + let probe = stage.path().join("probe"); + fs::create_dir(&probe)?; + stage_parity_files(&probe, &request.parity_files, cancel)?; + stage_data_files(&probe, &file_set, &data_by_name, None, cancel)?; + + check_cancel(cancel)?; + check_active_time(request)?; + let verification = verify_with_cancel(&file_set, &probe, cancel)?; + check_cancel(cancel)?; + check_active_time(request)?; + if verification.all_correct() { + let names = file_set_names(&file_set)?; + let promoted_outputs = promote_outputs(&probe, &names, &data_by_name, cancel)?; + return Ok(Par2RepairResult { + report: Par2Report { + outcome: Par2Outcome::Verified, + recovered_bytes: 0, + }, + promoted_outputs, + }); + } + validate_repair_resources(&verification, &file_set)?; + if !verification.repair_possible { + return Err(Par2Error::InsufficientRecovery { + needed: verification.blocks_needed(), + available: verification.recovery_blocks_available, + }); + } + + let intact: HashSet<&str> = verification + .intact + .iter() + .map(|file| file.filename.as_str()) + .collect(); + fs::remove_dir_all(&probe)?; + let repair_dir = stage.path().join("repair"); + fs::create_dir(&repair_dir)?; + stage_parity_files(&repair_dir, &request.parity_files, cancel)?; + stage_data_files(&repair_dir, &file_set, &data_by_name, Some(&intact), cancel)?; + check_cancel(cancel)?; + check_active_time(request)?; + let repair = repair_from_verify_with_cancel(&file_set, &repair_dir, &verification, cancel) + .map_err(|error| match error { + Par2Error::Cancelled => Par2Error::Cancelled, + error => Par2Error::Repair(error.to_string()), + })?; + check_cancel(cancel)?; + check_active_time(request)?; + + let changed_names: BTreeSet = verification + .damaged + .iter() + .map(|file| file.filename.clone()) + .chain( + verification + .missing + .iter() + .map(|file| file.filename.clone()), + ) + .collect(); + let promoted_outputs = promote_outputs(&repair_dir, &changed_names, &data_by_name, cancel)?; + Ok(Par2RepairResult { + report: Par2Report { + outcome: Par2Outcome::Repaired, + recovered_bytes: (repair.blocks_repaired as u64).saturating_mul(file_set.slice_size), + }, + promoted_outputs, + }) +} + +fn check_cancel(cancel: Option<&CancellationToken>) -> Result<(), Par2Error> { + if cancel.is_some_and(CancellationToken::is_cancelled) { + Err(Par2Error::Cancelled) + } else { + Ok(()) + } +} + +// rust-par2's public verify/repair entry points are intentionally blocking and +// do not expose a cancellation hook. Keep the work in this module chunked so +// a worker cancellation can be observed during hashing, decoding, and writes. +fn verify_with_cancel( + file_set: &rust_par2::Par2FileSet, + dir: &Path, + cancel: Option<&CancellationToken>, +) -> Result { + verify_with_cancel_inner(file_set, dir, cancel, None) +} + +#[cfg(test)] +fn verify_with_cancel_hook( + file_set: &rust_par2::Par2FileSet, + dir: &Path, + cancel: Option<&CancellationToken>, + hook: &mut dyn FnMut(), +) -> Result { + verify_with_cancel_inner(file_set, dir, cancel, Some(hook)) +} + +fn verify_with_cancel_inner( + file_set: &rust_par2::Par2FileSet, + dir: &Path, + cancel: Option<&CancellationToken>, + mut hook: Option<&mut dyn FnMut()>, +) -> Result { + let mut files: Vec<_> = file_set.files.values().collect(); + files.sort_by_key(|file| &file.filename); + let mut intact = Vec::new(); + let mut damaged = Vec::new(); + let mut missing = Vec::new(); + + for file in files { + check_cancel(cancel)?; + let path = dir.join(&file.filename); + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(_) => { + missing.push(rust_par2::MissingFile { + filename: file.filename.clone(), + expected_size: file.size, + block_count: blocks_for_size(file.size, file_set.slice_size), + }); + continue; + } + }; + if metadata.len() != file.size { + let total = blocks_for_size(file.size, file_set.slice_size); + damaged.push(rust_par2::DamagedFile { + filename: file.filename.clone(), + size: metadata.len(), + damaged_block_count: total, + total_block_count: total, + damaged_block_indices: (0..total).collect(), + }); + continue; + } + + let hash = match if let Some(hook) = hook.as_mut() { + md5_file_with_cancel(&path, cancel, Some(&mut **hook)) + } else { + md5_file_with_cancel(&path, cancel, None) + } { + Ok(hash) => Some(hash), + Err(Par2Error::Cancelled) => return Err(Par2Error::Cancelled), + Err(_) => None, + }; + if hash == Some(file.hash) { + intact.push(rust_par2::VerifiedFile { + filename: file.filename.clone(), + size: file.size, + }); + continue; + } + let total = blocks_for_size(file.size, file_set.slice_size); + let bad_indices = if hash.is_none() { + (0..total).collect() + } else { + match damaged_blocks_with_cancel(&path, &file.slices, file_set.slice_size, cancel) { + Ok(indices) => indices, + Err(Par2Error::Cancelled) => return Err(Par2Error::Cancelled), + Err(_) => (0..file.slices.len() as u32).collect(), + } + }; + damaged.push(rust_par2::DamagedFile { + filename: file.filename.clone(), + size: metadata.len(), + damaged_block_count: bad_indices.len() as u32, + total_block_count: total, + damaged_block_indices: bad_indices, + }); + } + + let recovery_blocks_available = recovery_block_count_with_cancel(dir, file_set, cancel)?; + let blocks_needed = damaged + .iter() + .map(|file| file.damaged_block_count) + .sum::() + .saturating_add(missing.iter().map(|file| file.block_count).sum::()); + Ok(rust_par2::VerifyResult { + intact, + damaged, + missing, + recovery_blocks_available, + repair_possible: blocks_needed <= recovery_blocks_available, + }) +} + +fn blocks_for_size(size: u64, slice_size: u64) -> u32 { + if slice_size == 0 { + 0 + } else { + size.div_ceil(slice_size) as u32 + } +} + +fn md5_file_with_cancel( + path: &Path, + cancel: Option<&CancellationToken>, + mut hook: Option<&mut dyn FnMut()>, +) -> Result<[u8; 16], Par2Error> { + let mut file = fs::File::open(path)?; + let mut hasher = Md5::new(); + let mut buffer = vec![0u8; VERIFY_HASH_BUFFER_BYTES as usize]; + loop { + check_cancel(cancel)?; + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + if let Some(hook) = hook.as_deref_mut() { + hook(); + } + } + Ok(hasher.finalize().into()) +} + +fn damaged_blocks_with_cancel( + path: &Path, + slices: &[rust_par2::SliceChecksum], + slice_size: u64, + cancel: Option<&CancellationToken>, +) -> Result, Par2Error> { + if slices.is_empty() { + return Ok(Vec::new()); + } + let mut file = fs::File::open(path)?; + let mut buffer = vec![0u8; slice_size as usize]; + let mut damaged = Vec::new(); + for (index, expected) in slices.iter().enumerate() { + check_cancel(cancel)?; + let mut read = 0; + let mut reached_eof = false; + while read < buffer.len() { + let chunk = file.read(&mut buffer[read..])?; + if chunk == 0 { + reached_eof = true; + break; + } + read += chunk; + check_cancel(cancel)?; + } + if read == 0 { + damaged.extend(index as u32..slices.len() as u32); + break; + } + let mut hasher = Md5::new(); + hasher.update(&buffer[..read]); + if read < buffer.len() { + hasher.update(vec![0u8; buffer.len() - read]); + } + let hash: [u8; 16] = hasher.finalize().into(); + if hash != expected.md5 { + damaged.push(index as u32); + } + if reached_eof { + damaged.extend((index as u32 + 1)..slices.len() as u32); + break; + } + } + Ok(damaged) +} + +fn recovery_block_count_with_cancel( + dir: &Path, + file_set: &rust_par2::Par2FileSet, + cancel: Option<&CancellationToken>, +) -> Result { + let mut count = 0u32; + let read_dir = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return Ok(file_set.recovery_block_count), + }; + let mut entries = read_dir + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("par2")) + }) + .collect::>(); + entries.sort(); + for path in entries { + check_cancel(cancel)?; + if let Ok(parsed) = rust_par2::parse(&path) { + if parsed.recovery_set_id == file_set.recovery_set_id { + count = count.saturating_add(parsed.recovery_block_count); + } + } + } + Ok(if count == 0 { + file_set.recovery_block_count + } else { + count + }) +} + +fn repair_from_verify_with_cancel( + file_set: &rust_par2::Par2FileSet, + dir: &Path, + verification: &rust_par2::VerifyResult, + cancel: Option<&CancellationToken>, +) -> Result { + check_cancel(cancel)?; + if verification.all_correct() { + return Err(Par2Error::Repair( + "No damage detected — nothing to repair".into(), + )); + } + let blocks_needed = verification.blocks_needed() as usize; + let recovery_blocks = load_recovery_blocks_with_cancel( + dir, + &file_set.recovery_set_id, + file_set.slice_size, + cancel, + )?; + if recovery_blocks.len() < blocks_needed { + return Err(Par2Error::Repair(format!( + "Insufficient recovery data: need {}, have {}", + blocks_needed, + recovery_blocks.len() + ))); + } + + let block_map = CancellableBlockMap::new(file_set); + let damaged_indices = cancellable_damaged_indices(verification, &block_map); + let damaged_count = damaged_indices.len(); + let recovery_to_use = recovery_blocks + .iter() + .take(damaged_count) + .collect::>(); + let recovery_exponents = recovery_to_use + .iter() + .map(|block| block.exponent) + .collect::>(); + let constants = rust_par2::matrix::par2_input_constants(block_map.total_blocks as usize); + let mut matrix = rust_par2::matrix::GfMatrix::zeros(damaged_count, damaged_count); + for (row, exponent) in recovery_exponents.iter().copied().enumerate() { + check_cancel(cancel)?; + for (column, index) in damaged_indices.iter().copied().enumerate() { + matrix.set(row, column, rust_par2::gf::pow(constants[index], exponent)); + } + } + let inverse = invert_matrix_with_cancel(&matrix, cancel)?; + let slice_size = file_set.slice_size as usize; + let damaged_set = damaged_indices.iter().copied().collect::>(); + let mut adjusted = recovery_to_use + .iter() + .map(|block| block.data.clone()) + .collect::>(); + let intact_indices = (0..block_map.total_blocks as usize) + .filter(|index| !damaged_set.contains(index)) + .collect::>(); + let mut file_handles = HashMap::new(); + for batch in intact_indices.chunks(PAR2_REPAIR_BATCH_BLOCKS as usize) { + check_cancel(cancel)?; + let batch_data = batch + .iter() + .map(|&index| { + read_source_block_with_cancel( + dir, + &block_map, + index, + slice_size, + &mut file_handles, + cancel, + ) + }) + .collect::, _>>()?; + for (row, adjusted_block) in adjusted.iter_mut().enumerate() { + let coefficients = batch + .iter() + .map(|&index| rust_par2::gf::pow(constants[index], recovery_exponents[row])) + .collect::>(); + for (source, coefficient) in batch_data.iter().zip(coefficients) { + mul_add_cancellable(adjusted_block, source, coefficient, cancel)?; + } + } + } + + let adjusted_refs = adjusted.iter().map(Vec::as_slice).collect::>(); + let mut outputs = (0..damaged_count) + .map(|_| vec![0u8; slice_size]) + .collect::>(); + for (row, output) in outputs.iter_mut().enumerate() { + check_cancel(cancel)?; + for column in 0..damaged_count { + mul_add_cancellable( + output, + adjusted_refs[column], + inverse.get(row, column), + cancel, + )?; + } + } + + let repaired_blocks = damaged_indices + .iter() + .copied() + .zip(outputs) + .collect::>(); + let mut files_touched = HashSet::new(); + for (global_index, data) in &repaired_blocks { + check_cancel(cancel)?; + let (filename, offset, write_len) = block_map.global_to_file(*global_index, slice_size); + let path = dir.join(&filename); + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&path)?; + let expected_size = block_map + .files + .iter() + .find(|entry| entry.filename == filename) + .map(|entry| entry.file_size) + .unwrap_or_default(); + if file.metadata()?.len() < expected_size { + file.set_len(expected_size)?; + } + file.seek(SeekFrom::Start(offset as u64))?; + file.write_all(&data[..write_len])?; + files_touched.insert(filename); + } + + let post_repair = verify_with_cancel(file_set, dir, cancel)?; + if !post_repair.all_correct() { + return Err(Par2Error::Repair(format!( + "Verification after repair failed: {post_repair}" + ))); + } + Ok(rust_par2::RepairResult { + success: true, + blocks_repaired: repaired_blocks.len() as u32, + files_repaired: files_touched.len(), + message: "All files repaired and verified".into(), + }) +} + +fn invert_matrix_with_cancel( + matrix: &rust_par2::matrix::GfMatrix, + cancel: Option<&CancellationToken>, +) -> Result { + if matrix.rows != matrix.cols { + return Err(Par2Error::Repair("Decode matrix is not square".into())); + } + let size = matrix.rows; + let mut augmented = rust_par2::matrix::GfMatrix::zeros(size, size * 2); + for row in 0..size { + for column in 0..size { + augmented.set(row, column, matrix.get(row, column)); + } + augmented.set(row, size + row, 1); + } + for column in 0..size { + check_cancel(cancel)?; + let pivot = (column..size) + .find(|&row| augmented.get(row, column) != 0) + .ok_or_else(|| Par2Error::Repair("Decode matrix is singular".into()))?; + if pivot != column { + for index in 0..size * 2 { + let value = augmented.get(column, index); + augmented.set(column, index, augmented.get(pivot, index)); + augmented.set(pivot, index, value); + } + } + let inverse = rust_par2::gf::inv(augmented.get(column, column)); + for index in 0..size * 2 { + augmented.set( + column, + index, + rust_par2::gf::mul(augmented.get(column, index), inverse), + ); + } + for row in 0..size { + if row == column { + continue; + } + let factor = augmented.get(row, column); + if factor == 0 { + continue; + } + for index in 0..size * 2 { + let value = rust_par2::gf::mul(factor, augmented.get(column, index)); + augmented.set(row, index, augmented.get(row, index) ^ value); + } + } + } + let mut inverse = rust_par2::matrix::GfMatrix::zeros(size, size); + for row in 0..size { + for column in 0..size { + inverse.set(row, column, augmented.get(row, size + column)); + } + } + Ok(inverse) +} + +fn mul_add_cancellable( + destination: &mut [u8], + source: &[u8], + coefficient: u16, + cancel: Option<&CancellationToken>, +) -> Result<(), Par2Error> { + const CHUNK_BYTES: usize = 1024 * 1024; + for (destination, source) in destination + .chunks_mut(CHUNK_BYTES) + .zip(source.chunks(CHUNK_BYTES)) + { + check_cancel(cancel)?; + rust_par2::gf_simd_public::mul_add_buffer(destination, source, coefficient); + } + Ok(()) +} + +fn load_recovery_blocks_with_cancel( + dir: &Path, + set_id: &[u8; 16], + slice_size: u64, + cancel: Option<&CancellationToken>, +) -> Result, Par2Error> { + let mut paths = fs::read_dir(dir)? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("par2")) + }) + .collect::>(); + paths.sort(); + let mut blocks = Vec::new(); + for path in paths { + check_cancel(cancel)?; + let mut file = fs::File::open(path)?; + let file_size = file.metadata()?.len(); + let mut position = 0u64; + let mut header = [0u8; PAR2_HEADER_BYTES as usize]; + while position + PAR2_HEADER_BYTES <= file_size { + check_cancel(cancel)?; + file.seek(SeekFrom::Start(position))?; + if file.read_exact(&mut header).is_err() { + break; + } + if &header[..8] != PAR2_MAGIC { + position = position.saturating_add(4); + continue; + } + let packet_length = u64::from_le_bytes(header[8..16].try_into().unwrap()); + if packet_length < PAR2_HEADER_BYTES || packet_length % 4 != 0 { + position = position.saturating_add(4); + continue; + } + if header[32..48] != *set_id || &header[48..64] != b"PAR 2.0\0RecvSlic" { + position = position.saturating_add(packet_length); + continue; + } + let body_length = packet_length - PAR2_HEADER_BYTES; + let expected_body = 4u64.saturating_add(slice_size); + if body_length >= expected_body && expected_body <= usize::MAX as u64 { + file.seek(SeekFrom::Start(position + PAR2_HEADER_BYTES))?; + let mut body = vec![0u8; expected_body as usize]; + read_exact_with_cancel(&mut file, &mut body, cancel)?; + blocks.push(rust_par2::recovery::RecoveryBlock { + exponent: u32::from_le_bytes(body[..4].try_into().unwrap()), + data: body[4..].to_vec(), + }); + } + position = position.saturating_add(packet_length); + } + } + blocks.sort_by_key(|block| block.exponent); + Ok(blocks) +} + +fn read_exact_with_cancel( + file: &mut fs::File, + buffer: &mut [u8], + cancel: Option<&CancellationToken>, +) -> Result<(), Par2Error> { + let mut offset = 0; + while offset < buffer.len() { + check_cancel(cancel)?; + let read = file.read(&mut buffer[offset..])?; + if read == 0 { + return Err(Par2Error::Io(io::Error::new( + io::ErrorKind::UnexpectedEof, + "short PAR2 recovery packet", + ))); + } + offset += read; + } + Ok(()) +} + +struct CancellableBlockMap { + files: Vec, + total_blocks: u32, +} + +struct CancellableBlockFile { + filename: String, + file_size: u64, + block_count: u32, + start_block: u32, +} + +impl CancellableBlockMap { + fn new(file_set: &rust_par2::Par2FileSet) -> Self { + let ordered = if file_set.file_order.is_empty() { + let mut files = file_set.files.values().collect::>(); + files.sort_by_key(|file| file.file_id); + files + } else { + file_set + .file_order + .iter() + .filter_map(|id| file_set.files.get(id)) + .collect::>() + }; + let mut start_block = 0u32; + let mut files = Vec::with_capacity(ordered.len()); + for file in ordered { + let block_count = blocks_for_size(file.size, file_set.slice_size); + files.push(CancellableBlockFile { + filename: file.filename.clone(), + file_size: file.size, + block_count, + start_block, + }); + start_block = start_block.saturating_add(block_count); + } + Self { + files, + total_blocks: start_block, + } + } + + fn global_to_file(&self, global_index: usize, slice_size: usize) -> (String, usize, usize) { + let global = global_index as u32; + for file in &self.files { + if global >= file.start_block && global < file.start_block + file.block_count { + let local = (global - file.start_block) as usize; + let offset = local * slice_size; + let remaining = file.file_size as usize - offset; + return (file.filename.clone(), offset, remaining.min(slice_size)); + } + } + panic!("global PAR2 block index {global_index} is out of range"); + } +} + +fn cancellable_damaged_indices( + verification: &rust_par2::VerifyResult, + block_map: &CancellableBlockMap, +) -> Vec { + let mut indices = Vec::new(); + for damaged in &verification.damaged { + if let Some(file) = block_map + .files + .iter() + .find(|file| file.filename == damaged.filename) + { + if damaged.damaged_block_indices.is_empty() { + indices.extend( + (file.start_block..file.start_block + file.block_count) + .map(|index| index as usize), + ); + } else { + indices.extend(damaged.damaged_block_indices.iter().filter_map(|&index| { + (index < file.block_count).then_some((file.start_block + index) as usize) + })); + } + } + } + for missing in &verification.missing { + if let Some(file) = block_map + .files + .iter() + .find(|file| file.filename == missing.filename) + { + indices.extend( + (file.start_block..file.start_block + file.block_count).map(|index| index as usize), + ); + } + } + indices.sort_unstable(); + indices.dedup(); + indices.into_iter().map(|index| index as usize).collect() +} + +fn read_source_block_with_cancel( + dir: &Path, + block_map: &CancellableBlockMap, + global_index: usize, + slice_size: usize, + file_handles: &mut HashMap, + cancel: Option<&CancellationToken>, +) -> Result, Par2Error> { + let (filename, offset, _) = block_map.global_to_file(global_index, slice_size); + let handle = match file_handles.entry(filename.clone()) { + std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(fs::File::open(dir.join(&filename))?) + } + }; + handle.seek(SeekFrom::Start(offset as u64))?; + let mut buffer = vec![0u8; slice_size]; + let mut read_total = 0; + while read_total < slice_size { + check_cancel(cancel)?; + match handle.read(&mut buffer[read_total..])? { + 0 => break, + read => read_total += read, + } + } + Ok(buffer) +} + +fn check_active_time(request: &Par2RepairRequest) -> Result<(), Par2Error> { + let repair_elapsed = request + .active_started_at + .map(|started| started.elapsed()) + .unwrap_or_default(); + let active_elapsed = request + .active_elapsed_before_repair + .unwrap_or_default() + .checked_add(repair_elapsed) + .unwrap_or(Duration::MAX); + if active_elapsed.as_secs() > request.limits.max_active_seconds { + return Err(Par2Error::Limits( + "PAR2 repair exceeded the active-time limit".into(), + )); + } + Ok(()) +} + +pub fn platform_limits() -> ArchiveLimits { + if cfg!(target_os = "android") { + ArchiveLimits::android_defaults() + } else { + ArchiveLimits::desktop_defaults() + } +} + +fn repair_memory_limit() -> u64 { + if cfg!(target_os = "android") { + 128 * 1024 * 1024 + } else { + 512 * 1024 * 1024 + } +} + +fn metadata_memory_limit() -> u64 { + if cfg!(target_os = "android") { + 16 * 1024 * 1024 + } else { + 64 * 1024 * 1024 + } +} + +#[derive(Debug, Clone, Copy)] +struct ParityStats { + bytes: u64, + packets: u64, + recovery_blocks: u64, +} + +#[derive(Debug, Clone, Copy, Default)] +struct ParityFileStats { + packets: u64, + recovery_blocks: u64, + metadata_bytes: u64, + source_file_descriptors: u64, + source_blocks: u64, +} + +#[derive(Debug, Clone)] +struct ValidatedParityFile { + path: PathBuf, +} + +#[derive(Debug, Clone)] +struct ValidatedParityFiles { + total: ParityStats, + files: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct SourceStats { + bytes: u64, +} + +fn validate_task_budget( + request: &Par2RepairRequest, + source: SourceStats, + parity: ParityStats, +) -> Result<(), Par2Error> { + let total_entries = (request.data_files.len() as u64) + .checked_add(request.parity_files.len() as u64) + .ok_or_else(|| Par2Error::Limits("PAR2 entry count overflowed".into()))?; + if total_entries > request.limits.max_entries { + return Err(Par2Error::Limits("too many data and PAR2 files".into())); + } + if parity.packets > MAX_PAR2_PACKET_COUNT { + return Err(Par2Error::Limits("too many PAR2 packets".into())); + } + if parity.recovery_blocks > u64::from(MAX_PAR2_RECOVERY_BLOCKS) { + return Err(Par2Error::Limits( + "PAR2 recovery set has too many blocks for in-process repair".into(), + )); + } + if parity.bytes > MAX_PAR2_INPUT_BYTES { + return Err(Par2Error::Limits("parity input is too large".into())); + } + let total_bytes = source + .bytes + .checked_add(parity.bytes) + .ok_or_else(|| Par2Error::Limits("PAR2 task size overflowed".into()))?; + if total_bytes > request.limits.max_expanded_bytes { + return Err(Par2Error::Limits( + "data and PAR2 files exceed the task-wide limit".into(), + )); + } + Ok(()) +} + +fn ensure_staging_space( + request: &Par2RepairRequest, + source_bytes: u64, + parity_bytes: u64, +) -> Result<(), Par2Error> { + let staging_bytes = source_bytes + .checked_add(parity_bytes) + .ok_or_else(|| Par2Error::Limits("PAR2 staging size overflowed".into()))?; + let required = staging_bytes + .checked_add(request.limits.free_space_reserve_bytes) + .ok_or_else(|| Par2Error::Limits("PAR2 staging reserve overflowed".into()))?; + let available = fs4::available_space(&request.destination)?; + if available < required { + return Err(Par2Error::Limits( + "insufficient free space for PAR2 staging and reserve".into(), + )); + } + Ok(()) +} + +fn validate_verification_resources(file_set: &rust_par2::Par2FileSet) -> Result<(), Par2Error> { + let workers = std::thread::available_parallelism() + .map(|count| count.get() as u64) + .unwrap_or(1); + let per_worker = file_set + .slice_size + .checked_add(VERIFY_HASH_BUFFER_BYTES.saturating_mul(2)) + .ok_or_else(|| Par2Error::Limits("PAR2 verification memory overflowed".into()))?; + let working_set = workers + .checked_mul(per_worker) + .ok_or_else(|| Par2Error::Limits("PAR2 verification memory overflowed".into()))?; + if working_set > repair_memory_limit() { + return Err(Par2Error::Limits( + "PAR2 verification would exceed the in-process memory limit".into(), + )); + } + Ok(()) +} + +fn validate_repair_resources( + verification: &rust_par2::VerifyResult, + file_set: &rust_par2::Par2FileSet, +) -> Result<(), Par2Error> { + // This estimate matches rust-par2 0.1.3: its recovery blocks, repair + // matrix, paired repair buffers, and verification worker buffers are all + // live during repair. + let needed = verification.blocks_needed(); + let available = verification.recovery_blocks_available; + if needed > MAX_PAR2_REPAIR_BLOCKS || available > MAX_PAR2_RECOVERY_BLOCKS { + return Err(Par2Error::Limits( + "PAR2 recovery set has too many blocks for in-process repair".into(), + )); + } + let needed = u64::from(needed); + let available = u64::from(available); + let slice_size = file_set.slice_size; + let matrix_cells = needed + .checked_mul(needed) + .ok_or_else(|| Par2Error::Limits("PAR2 repair matrix size overflowed".into()))?; + let matrix_bytes = matrix_cells + .checked_mul(PAR2_REPAIR_MATRIX_BYTES_PER_CELL) + .ok_or_else(|| Par2Error::Limits("PAR2 repair matrix memory overflowed".into()))?; + let recovery_bytes = available + .checked_mul(slice_size) + .ok_or_else(|| Par2Error::Limits("PAR2 recovery memory calculation overflowed".into()))?; + let repair_buffers = needed + .checked_mul(slice_size) + .and_then(|bytes| bytes.checked_mul(2)) + .ok_or_else(|| Par2Error::Limits("PAR2 repair buffer calculation overflowed".into()))?; + let parallelism = std::thread::available_parallelism() + .map(|count| count.get() as u64) + .unwrap_or(1); + let verification_buffers = parallelism + .checked_mul(2) + .and_then(|workers| workers.checked_add(PAR2_REPAIR_BATCH_BLOCKS)) + .and_then(|blocks| blocks.checked_mul(slice_size)) + .ok_or_else(|| { + Par2Error::Limits("PAR2 verification buffer calculation overflowed".into()) + })?; + let working_set = recovery_bytes + .checked_add(repair_buffers) + .and_then(|bytes| bytes.checked_add(matrix_bytes)) + .and_then(|bytes| bytes.checked_add(verification_buffers)) + .ok_or_else(|| Par2Error::Limits("PAR2 repair memory calculation overflowed".into()))?; + if working_set > repair_memory_limit() { + return Err(Par2Error::Limits( + "PAR2 repair would exceed the in-process memory limit".into(), + )); + } + Ok(()) +} + +fn validate_input_files( + inputs: &[Par2InputFile], + limits: ArchiveLimits, +) -> Result, Par2Error> { + let mut by_name = HashMap::new(); + let mut portable_names = HashSet::new(); + for input in inputs { + validate_simple_filename(&input.manifest_name)?; + if !portable_names.insert(portable_filename_key(&input.manifest_name)) { + return Err(Par2Error::Malformed(format!( + "NZB output name {} collides on case-insensitive filesystems", + input.manifest_name + ))); + } + if input + .expected_size + .is_some_and(|size| size > limits.max_entry_bytes) + { + return Err(Par2Error::Limits(format!( + "{} exceeds the per-file limit", + input.manifest_name + ))); + } + ensure_regular_file(&input.source_path)?; + if fs::metadata(&input.source_path)?.len() > limits.max_entry_bytes { + return Err(Par2Error::Limits(format!( + "{} exceeds the per-file limit", + input.manifest_name + ))); + } + if by_name.insert(input.manifest_name.clone(), input).is_some() { + return Err(Par2Error::Malformed(format!( + "duplicate NZB output name {}", + input.manifest_name + ))); + } + } + if by_name.is_empty() { + return Err(Par2Error::Malformed("no data files were supplied".into())); + } + Ok(by_name) +} + +fn validate_parity_files(paths: &[PathBuf]) -> Result { + let mut bytes = 0u64; + let mut packets = 0u64; + let mut recovery_blocks = 0u64; + let mut files = Vec::with_capacity(paths.len()); + for path in paths { + ensure_regular_file(path)?; + let size = fs::metadata(path)?.len(); + if size > MAX_PAR2_FILE_BYTES { + return Err(Par2Error::Limits(format!( + "{} exceeds the per-file parity limit", + path.display() + ))); + } + bytes = bytes + .checked_add(size) + .ok_or_else(|| Par2Error::Limits("parity byte count overflowed".into()))?; + let stats = validate_par2_packets(path)?; + packets = packets + .checked_add(stats.packets) + .ok_or_else(|| Par2Error::Limits("PAR2 packet count overflowed".into()))?; + recovery_blocks = recovery_blocks + .checked_add(stats.recovery_blocks) + .ok_or_else(|| Par2Error::Limits("PAR2 recovery block count overflowed".into()))?; + files.push(ValidatedParityFile { path: path.clone() }); + } + if bytes > MAX_PAR2_INPUT_BYTES { + return Err(Par2Error::Limits("parity input is too large".into())); + } + if packets > MAX_PAR2_PACKET_COUNT { + return Err(Par2Error::Limits("too many PAR2 packets".into())); + } + if recovery_blocks > u64::from(MAX_PAR2_RECOVERY_BLOCKS) { + return Err(Par2Error::Limits( + "PAR2 recovery set has too many blocks for in-process repair".into(), + )); + } + Ok(ValidatedParityFiles { + total: ParityStats { + bytes, + packets, + recovery_blocks, + }, + files, + }) +} + +fn validate_par2_packets(path: &Path) -> Result { + let file_size = fs::metadata(path)?.len(); + if file_size < PAR2_HEADER_BYTES { + return Err(Par2Error::Malformed(format!( + "{} is smaller than one PAR2 packet", + path.display() + ))); + } + let mut reader = BufReader::new(fs::File::open(path)?); + let mut position = 0u64; + let mut stats = ParityFileStats::default(); + let mut recovery_set_id = None; + let mut buffer = vec![0u8; SPARSE_COPY_BUFFER_BYTES]; + while position < file_size { + if file_size - position < PAR2_HEADER_BYTES { + return Err(Par2Error::Malformed(format!( + "{} ends in a truncated packet header", + path.display() + ))); + } + let mut magic = [0u8; 8]; + reader.read_exact(&mut magic)?; + let mut length_bytes = [0u8; 8]; + reader.read_exact(&mut length_bytes)?; + let packet_len = u64::from_le_bytes(length_bytes); + if magic != *PAR2_MAGIC + || packet_len < PAR2_HEADER_BYTES + || packet_len % 4 != 0 + || packet_len > file_size - position + || packet_len > MAX_PAR2_SLICE_BYTES + PAR2_HEADER_BYTES + { + return Err(Par2Error::Malformed(format!( + "{} contains an invalid PAR2 packet length", + path.display() + ))); + } + let mut expected_md5 = [0u8; 16]; + reader.read_exact(&mut expected_md5)?; + let mut hasher = Md5::new(); + let mut packet_prefix = [0u8; 32]; + reader.read_exact(&mut packet_prefix)?; + hasher.update(packet_prefix); + let packet_set_id: [u8; 16] = packet_prefix[..16] + .try_into() + .map_err(|_| Par2Error::Malformed("invalid PAR2 recovery set id".into()))?; + if let Some(expected) = recovery_set_id { + if expected != packet_set_id { + return Err(Par2Error::Malformed(format!( + "{} mixes PAR2 recovery set IDs", + path.display() + ))); + } + } else { + recovery_set_id = Some(packet_set_id); + } + let packet_type: [u8; 16] = packet_prefix[16..] + .try_into() + .map_err(|_| Par2Error::Malformed("invalid PAR2 packet type".into()))?; + let body_len = packet_len - PAR2_HEADER_BYTES; + let mut remaining = body_len; + + if packet_type == *PAR2_TYPE_RECOVERY { + if body_len < 4 { + return Err(Par2Error::Malformed(format!( + "{} contains a truncated PAR2 recovery packet", + path.display() + ))); + } + stats.recovery_blocks = stats + .recovery_blocks + .checked_add(1) + .ok_or_else(|| Par2Error::Limits("PAR2 recovery block count overflowed".into()))?; + } else { + if packet_len > MAX_PAR2_METADATA_PACKET_BYTES { + return Err(Par2Error::Limits(format!( + "{} contains an oversized PAR2 metadata packet", + path.display() + ))); + } + stats.metadata_bytes = stats + .metadata_bytes + .checked_add(packet_len) + .ok_or_else(|| Par2Error::Limits("PAR2 metadata byte count overflowed".into()))?; + if stats.metadata_bytes > metadata_memory_limit() { + return Err(Par2Error::Limits(format!( + "{} contains too much PAR2 metadata", + path.display() + ))); + } + if packet_type == *PAR2_TYPE_FILE_DESC { + if body_len < 56 { + return Err(Par2Error::Malformed(format!( + "{} contains a truncated PAR2 file description", + path.display() + ))); + } + stats.source_file_descriptors = stats + .source_file_descriptors + .checked_add(1) + .ok_or_else(|| Par2Error::Limits("PAR2 source file count overflowed".into()))?; + if stats.source_file_descriptors > MAX_PAR2_SOURCE_FILES { + return Err(Par2Error::Limits( + "PAR2 source set has too many files for in-process repair".into(), + )); + } + } else if packet_type == *PAR2_TYPE_IFSC { + if body_len < 16 || (body_len - 16) % 20 != 0 { + return Err(Par2Error::Malformed(format!( + "{} contains malformed PAR2 slice checksums", + path.display() + ))); + } + let slices = (body_len - 16) / 20; + stats.source_blocks = stats.source_blocks.checked_add(slices).ok_or_else(|| { + Par2Error::Limits("PAR2 source block count overflowed".into()) + })?; + if stats.source_blocks > MAX_PAR2_SOURCE_BLOCKS { + return Err(Par2Error::Limits( + "PAR2 source set has too many blocks for in-process repair".into(), + )); + } + } else if packet_type == *PAR2_TYPE_MAIN { + if body_len < 12 { + return Err(Par2Error::Malformed(format!( + "{} contains a truncated PAR2 main packet", + path.display() + ))); + } + let mut main_prefix = [0u8; 12]; + reader.read_exact(&mut main_prefix)?; + hasher.update(main_prefix); + remaining -= main_prefix.len() as u64; + let source_files = + u64::from(u32::from_le_bytes(main_prefix[8..].try_into().unwrap())); + let expected_body = 12u64 + .checked_add(source_files.checked_mul(16).ok_or_else(|| { + Par2Error::Limits("PAR2 main packet file order overflowed".into()) + })?) + .ok_or_else(|| Par2Error::Limits("PAR2 main packet size overflowed".into()))?; + if source_files > MAX_PAR2_SOURCE_FILES || body_len != expected_body { + return Err(Par2Error::Malformed(format!( + "{} contains an invalid PAR2 main packet file order", + path.display() + ))); + } + } + } + while remaining > 0 { + let count = remaining.min(buffer.len() as u64) as usize; + reader.read_exact(&mut buffer[..count])?; + hasher.update(&buffer[..count]); + remaining -= count as u64; + } + let actual_md5: [u8; 16] = hasher.finalize().into(); + if actual_md5 != expected_md5 { + return Err(Par2Error::Malformed(format!( + "{} contains a PAR2 packet with an invalid MD5", + path.display() + ))); + } + position += packet_len; + stats.packets += 1; + if stats.packets > MAX_PAR2_PACKET_COUNT { + return Err(Par2Error::Limits("too many PAR2 packets".into())); + } + } + Ok(stats) +} + +fn parse_index( + parity_files: &[ValidatedParityFile], + data_by_name: &HashMap, + required_incomplete_names: &BTreeSet, + limits: ArchiveLimits, +) -> Result<(rust_par2::Par2FileSet, SourceStats), Par2Error> { + let mut errors = Vec::new(); + for parity_file in parity_files { + match rust_par2::parse(&parity_file.path) { + Ok(file_set) => { + if file_set.files.is_empty() { + errors.push(format!( + "{} has no source files", + parity_file.path.display() + )); + continue; + } + match validate_file_set(&file_set, data_by_name, limits).and_then(|source| { + validate_incomplete_coverage(&file_set, required_incomplete_names)?; + Ok(source) + }) { + Ok(source) => return Ok((file_set, source)), + Err( + error + @ (Par2Error::UnsafePath(_) | Par2Error::Limits(_) | Par2Error::Io(_)), + ) => { + return Err(error); + } + Err(error) => errors.push(format!("{}: {error}", parity_file.path.display())), + } + } + Err(error) => errors.push(format!("{}: {error}", parity_file.path.display())), + } + } + Err(Par2Error::Malformed(if errors.is_empty() { + "no PAR2 index file was found".into() + } else { + errors.join("; ") + })) +} + +fn validate_incomplete_coverage( + file_set: &rust_par2::Par2FileSet, + required_incomplete_names: &BTreeSet, +) -> Result<(), Par2Error> { + if required_incomplete_names.is_empty() { + return Ok(()); + } + let covered = file_set_names(file_set)?; + let uncovered = required_incomplete_names + .iter() + .filter(|name| !covered.contains(*name)) + .cloned() + .collect::>(); + if uncovered.is_empty() { + Ok(()) + } else { + Err(Par2Error::Repair(format!( + "no complete PAR2 recovery set covers incomplete files {}", + uncovered.join(", ") + ))) + } +} + +fn validate_file_set( + file_set: &rust_par2::Par2FileSet, + data_by_name: &HashMap, + limits: ArchiveLimits, +) -> Result { + if file_set.slice_size == 0 + || file_set.slice_size > MAX_PAR2_SLICE_BYTES + || file_set.slice_size % 2 != 0 + { + return Err(Par2Error::Limits("invalid PAR2 slice size".into())); + } + if file_set.files.len() as u64 > limits.max_entries { + return Err(Par2Error::Limits("too many PAR2 source files".into())); + } + if file_set.files.len() as u64 > MAX_PAR2_SOURCE_FILES { + return Err(Par2Error::Limits( + "PAR2 source set has too many files for in-process repair".into(), + )); + } + let ordered_ids: HashSet<_> = file_set.file_order.iter().collect(); + if file_set.file_order.len() != file_set.files.len() + || ordered_ids.len() != file_set.files.len() + || file_set + .file_order + .iter() + .any(|id| !file_set.files.contains_key(id)) + { + return Err(Par2Error::Malformed("incomplete PAR2 file order".into())); + } + let mut names = HashSet::new(); + let mut portable_names = HashSet::new(); + let mut declared_bytes = 0u64; + let mut source_blocks = 0u64; + for file in file_set.files.values() { + validate_simple_filename(&file.filename)?; + validate_rust_par2_addressability(file.size, file_set.slice_size, usize::MAX as u64)?; + if !portable_names.insert(portable_filename_key(&file.filename)) { + return Err(Par2Error::Malformed(format!( + "PAR2 source filename {} collides on case-insensitive filesystems", + file.filename + ))); + } + let Some(input) = data_by_name.get(&file.filename) else { + return Err(Par2Error::Malformed(format!( + "PAR2 source file {} is not an NZB output", + file.filename + ))); + }; + if input.expected_size.is_some_and(|size| size != file.size) { + return Err(Par2Error::Malformed(format!( + "PAR2 size does not match yEnc size for {}", + file.filename + ))); + } + if file.size > limits.max_entry_bytes { + return Err(Par2Error::Limits(format!( + "{} exceeds the per-file limit", + file.filename + ))); + } + let expected_slices = file.size.div_ceil(file_set.slice_size); + if file.slices.len() as u64 != expected_slices { + return Err(Par2Error::Malformed(format!( + "PAR2 slice metadata is incomplete for {}", + file.filename + ))); + } + let source_len = fs::metadata(&input.source_path)?.len(); + if source_len > file.size { + return Err(Par2Error::Limits(format!( + "{} is larger than its PAR2 source size", + input.manifest_name + ))); + } + declared_bytes = declared_bytes + .checked_add(file.size) + .ok_or_else(|| Par2Error::Limits("PAR2 source size overflowed".into()))?; + if !names.insert(file.filename.as_str()) { + return Err(Par2Error::Malformed(format!( + "duplicate PAR2 source filename {}", + file.filename + ))); + } + source_blocks = source_blocks + .checked_add(expected_slices) + .ok_or_else(|| Par2Error::Limits("PAR2 source block count overflowed".into()))?; + } + if source_blocks > MAX_PAR2_SOURCE_BLOCKS { + return Err(Par2Error::Limits( + "PAR2 source set has too many blocks for in-process repair".into(), + )); + } + Ok(SourceStats { + bytes: declared_bytes, + }) +} + +fn file_set_names(file_set: &rust_par2::Par2FileSet) -> Result, Par2Error> { + let mut names = BTreeSet::new(); + let mut portable_names = HashSet::new(); + for file in file_set.files.values() { + validate_simple_filename(&file.filename)?; + if !portable_names.insert(portable_filename_key(&file.filename)) { + return Err(Par2Error::Malformed(format!( + "PAR2 source filename {} collides on case-insensitive filesystems", + file.filename + ))); + } + names.insert(file.filename.clone()); + } + Ok(names) +} + +fn portable_filename_key(name: &str) -> String { + name.to_lowercase() +} + +fn validate_rust_par2_addressability( + file_size: u64, + slice_size: u64, + max_usize: u64, +) -> Result<(), Par2Error> { + if file_size > max_usize || slice_size > max_usize { + return Err(Par2Error::Limits( + "PAR2 source size exceeds this platform's addressable range".into(), + )); + } + Ok(()) +} + +fn validate_simple_filename(name: &str) -> Result<(), Par2Error> { + validate_member_path(name).map_err(|_| Par2Error::UnsafePath(name.to_string()))?; + let mut components = Path::new(name).components(); + if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() { + return Err(Par2Error::UnsafePath(name.to_string())); + } + Ok(()) +} + +fn ensure_regular_file(path: &Path) -> Result<(), Par2Error> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(Par2Error::UnsafePath(path.display().to_string())); + } + Ok(()) +} + +fn stage_parity_files( + stage: &Path, + parity_files: &[PathBuf], + cancel: Option<&CancellationToken>, +) -> Result<(), Par2Error> { + for (index, source) in parity_files.iter().enumerate() { + check_cancel(cancel)?; + let destination = stage.join(format!("parity-{index:05}.par2")); + link_or_copy(source, &destination, cancel)?; + } + Ok(()) +} + +fn stage_data_files( + stage: &Path, + file_set: &rust_par2::Par2FileSet, + data_by_name: &HashMap, + intact_names: Option<&HashSet<&str>>, + cancel: Option<&CancellationToken>, +) -> Result<(), Par2Error> { + for file in file_set.files.values() { + check_cancel(cancel)?; + let input = data_by_name + .get(&file.filename) + .ok_or_else(|| Par2Error::UnsafePath(file.filename.clone()))?; + let destination = stage.join(&file.filename); + if intact_names.is_some_and(|names| names.contains(file.filename.as_str())) { + link_or_copy(&input.source_path, &destination, cancel)?; + } else { + sparse_copy(&input.source_path, &destination, cancel)?; + } + } + Ok(()) +} + +fn link_or_copy( + source: &Path, + destination: &Path, + cancel: Option<&CancellationToken>, +) -> Result<(), Par2Error> { + check_cancel(cancel)?; + ensure_regular_file(source)?; + match fs::hard_link(source, destination) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + Err(Par2Error::Malformed(format!( + "PAR2 staging path already exists: {}", + destination.display() + ))) + } + Err(_) => copy_file_with_cancel(source, destination, cancel), + } +} + +fn copy_file_with_cancel( + source: &Path, + destination: &Path, + cancel: Option<&CancellationToken>, +) -> Result<(), Par2Error> { + copy_file_with_cancel_inner(source, destination, cancel, None) +} + +fn copy_file_with_cancel_inner( + source: &Path, + destination: &Path, + cancel: Option<&CancellationToken>, + after_write: Option<&dyn Fn()>, +) -> Result<(), Par2Error> { + check_cancel(cancel)?; + let mut reader = BufReader::new(fs::File::open(source)?); + let mut writer = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(destination)?; + let mut buffer = vec![0u8; SPARSE_COPY_BUFFER_BYTES]; + let result = (|| { + loop { + check_cancel(cancel)?; + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + writer.write_all(&buffer[..read])?; + if let Some(after_write) = after_write { + after_write(); + } + check_cancel(cancel)?; + } + writer.sync_all()?; + Ok(()) + })(); + drop(writer); + if result.is_err() { + let _ = fs::remove_file(destination); + } + result +} + +fn sparse_copy( + source: &Path, + destination: &Path, + cancel: Option<&CancellationToken>, +) -> Result<(), Par2Error> { + ensure_regular_file(source)?; + let source_length = fs::metadata(source)?.len(); + let mut reader = BufReader::new(fs::File::open(source)?); + let mut writer = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(destination)?; + let mut buffer = vec![0u8; SPARSE_COPY_BUFFER_BYTES]; + let mut written = 0u64; + loop { + check_cancel(cancel)?; + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + if buffer[..read].iter().all(|byte| *byte == 0) { + writer.seek(SeekFrom::Current(read as i64))?; + } else { + writer.write_all(&buffer[..read])?; + } + written += read as u64; + } + if written != source_length { + return Err(Par2Error::Io(io::Error::new( + io::ErrorKind::UnexpectedEof, + "short read while staging PAR2 input", + ))); + } + writer.set_len(source_length)?; + writer.sync_all()?; + Ok(()) +} + +fn promote_outputs( + stage: &Path, + names: &BTreeSet, + data_by_name: &HashMap, + cancel: Option<&CancellationToken>, +) -> Result, Par2Error> { + let mut promotions = Vec::new(); + for name in names { + if let Err(error) = check_cancel(cancel) { + rollback_promotions(&mut promotions); + return Err(error); + } + let input = match data_by_name.get(name) { + Some(input) => input, + None => { + rollback_promotions(&mut promotions); + return Err(Par2Error::UnsafePath(name.clone())); + } + }; + let source = stage.join(name); + if let Err(error) = ensure_regular_file(&source) { + rollback_promotions(&mut promotions); + return Err(error); + } + if input.output_path.exists() && input.output_path != input.source_path { + rollback_promotions(&mut promotions); + return Err(Par2Error::Repair(format!( + "repair output {} appeared while the task was running", + input.output_path.display() + ))); + } + let promotion = match promote_one(&source, &input.output_path) { + Ok(promotion) => promotion, + Err(error) => { + rollback_promotions(&mut promotions); + return Err(error); + } + }; + promotions.push(promotion); + } + if let Err(error) = check_cancel(cancel) { + rollback_promotions(&mut promotions); + return Err(error); + } + let outputs = promotions + .iter() + .map(|promotion| promotion.destination.clone()) + .collect(); + for promotion in promotions { + if let Some(backup) = promotion.backup { + let _ = fs::remove_file(backup); + } + } + Ok(outputs) +} + +struct OutputPromotion { + source: PathBuf, + destination: PathBuf, + backup: Option, +} + +fn promote_one(source: &Path, destination: &Path) -> Result { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + if !destination.exists() { + fs::rename(source, destination)?; + return Ok(OutputPromotion { + source: source.to_path_buf(), + destination: destination.to_path_buf(), + backup: None, + }); + } + let file_name = destination + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("output"); + let backup = destination.with_file_name(format!( + ".{file_name}.risuko-par2-backup-{}", + uuid::Uuid::new_v4().simple() + )); + fs::rename(destination, &backup)?; + if let Err(error) = fs::rename(source, destination) { + let _ = fs::rename(&backup, destination); + return Err(Par2Error::Io(error)); + } + Ok(OutputPromotion { + source: source.to_path_buf(), + destination: destination.to_path_buf(), + backup: Some(backup), + }) +} + +fn rollback_promotions(promotions: &mut Vec) { + for promotion in promotions.drain(..).rev() { + if promotion.destination.exists() { + let _ = fs::rename(&promotion.destination, &promotion.source); + } + if let Some(backup) = promotion.backup { + if backup.exists() { + let _ = fs::rename(backup, promotion.destination); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TYPE_MAIN: &[u8; 16] = b"PAR 2.0\0Main\0\0\0\0"; + const TYPE_FILE_DESC: &[u8; 16] = b"PAR 2.0\0FileDesc"; + const TYPE_IFSC: &[u8; 16] = b"PAR 2.0\0IFSC\0\0\0\0"; + const TYPE_RECOVERY: &[u8; 16] = b"PAR 2.0\0RecvSlic"; + + fn build_packet(set_id: [u8; 16], packet_type: &[u8; 16], body: &[u8]) -> Vec { + let mut data = Vec::with_capacity(32 + body.len()); + data.extend_from_slice(&set_id); + data.extend_from_slice(packet_type); + data.extend_from_slice(body); + let mut packet = Vec::with_capacity(64 + body.len()); + packet.extend_from_slice(PAR2_MAGIC); + packet.extend_from_slice(&((64 + body.len()) as u64).to_le_bytes()); + packet.extend_from_slice(&Md5::digest(&data)); + packet.extend_from_slice(&data); + packet + } + + fn md5(bytes: &[u8]) -> [u8; 16] { + Md5::digest(bytes).into() + } + + fn write_fixture(parity_dir: &Path, name: &str, data: &[u8], recovery: bool) -> Vec { + let set_id = [0x5a; 16]; + let file_id = [0x11; 16]; + let slice_size = 4usize; + let mut index = Vec::new(); + let mut main = Vec::new(); + main.extend_from_slice(&(slice_size as u64).to_le_bytes()); + main.extend_from_slice(&1u32.to_le_bytes()); + main.extend_from_slice(&file_id); + index.extend_from_slice(&build_packet(set_id, TYPE_MAIN, &main)); + + let mut desc = Vec::new(); + desc.extend_from_slice(&file_id); + desc.extend_from_slice(&md5(data)); + desc.extend_from_slice(&md5(&data[..data.len().min(16_384)])); + desc.extend_from_slice(&(data.len() as u64).to_le_bytes()); + desc.extend_from_slice(name.as_bytes()); + desc.push(0); + while desc.len() % 4 != 0 { + desc.push(0); + } + index.extend_from_slice(&build_packet(set_id, TYPE_FILE_DESC, &desc)); + + let mut ifsc = Vec::new(); + ifsc.extend_from_slice(&file_id); + let mut blocks = Vec::new(); + for chunk in data.chunks(slice_size) { + let mut block = vec![0u8; slice_size]; + block[..chunk.len()].copy_from_slice(chunk); + ifsc.extend_from_slice(&md5(&block)); + ifsc.extend_from_slice(&crate::engine::usenet_pipeline::crc32(&block).to_le_bytes()); + blocks.push(block); + } + index.extend_from_slice(&build_packet(set_id, TYPE_IFSC, &ifsc)); + let index_path = parity_dir.join("sample.par2"); + fs::write(&index_path, &index).unwrap(); + if !recovery { + return vec![index_path]; + } + + let mut recovery_data = vec![0u8; slice_size]; + for block in blocks { + for (target, value) in recovery_data.iter_mut().zip(block) { + *target ^= value; + } + } + let mut recovery_body = 0u32.to_le_bytes().to_vec(); + recovery_body.extend_from_slice(&recovery_data); + let mut volume = index; + volume.extend_from_slice(&build_packet(set_id, TYPE_RECOVERY, &recovery_body)); + let volume_path = parity_dir.join("sample.vol00+1.par2"); + fs::write(&volume_path, volume).unwrap(); + vec![index_path, volume_path] + } + + fn repair_request(dir: &Path, parity_files: Vec) -> Par2RepairRequest { + let mut limits = platform_limits(); + limits.free_space_reserve_bytes = 0; + Par2RepairRequest { + destination: dir.to_path_buf(), + data_files: vec![Par2InputFile { + manifest_name: "data.bin".into(), + source_path: dir.join("data.bin.part"), + output_path: dir.join("data.bin"), + expected_size: Some(8), + }], + parity_files, + required_incomplete_names: BTreeSet::from(["data.bin".to_string()]), + limits, + active_started_at: None, + active_elapsed_before_repair: None, + } + } + + fn test_limits(max_expanded_bytes: u64) -> ArchiveLimits { + ArchiveLimits { + max_entries: 10, + max_expanded_bytes, + max_entry_bytes: max_expanded_bytes, + max_nesting_depth: 1, + max_compression_ratio: 1, + free_space_reserve_bytes: 0, + max_active_seconds: 60, + } + } + + #[test] + fn repair_time_accounts_for_active_assembly_time() { + let dir = tempfile::tempdir().unwrap(); + let mut request = repair_request(dir.path(), Vec::new()); + request.limits.max_active_seconds = 60; + request.active_elapsed_before_repair = Some(Duration::from_secs(61)); + + let error = check_active_time(&request).unwrap_err(); + + assert!(matches!(error, Par2Error::Limits(_))); + } + + #[test] + fn repairs_partial_file_without_changing_original_part_on_failure_paths() { + let dir = tempfile::tempdir().unwrap(); + let parity = tempfile::tempdir().unwrap(); + let complete = b"ABCDEFGH"; + let parity_files = write_fixture(parity.path(), "data.bin", complete, true); + let partial = b"ABCD\0\0\0\0"; + fs::write(dir.path().join("data.bin.part"), partial).unwrap(); + + let result = verify_or_repair(&repair_request(dir.path(), parity_files)).unwrap(); + + assert_eq!(result.report.outcome, Par2Outcome::Repaired); + assert_eq!(fs::read(dir.path().join("data.bin")).unwrap(), complete); + assert_eq!(fs::read(dir.path().join("data.bin.part")).unwrap(), partial); + } + + #[test] + fn verifies_complete_data_before_the_worker_accepts_it() { + let dir = tempfile::tempdir().unwrap(); + let parity = tempfile::tempdir().unwrap(); + let complete = b"ABCDEFGH"; + let parity_files = write_fixture(parity.path(), "data.bin", complete, true); + let output = dir.path().join("data.bin"); + fs::write(&output, complete).unwrap(); + let mut request = repair_request(dir.path(), parity_files); + request.data_files[0].source_path = output.clone(); + request.required_incomplete_names.clear(); + + let result = verify_or_repair(&request).unwrap(); + + assert_eq!(result.report.outcome, Par2Outcome::Verified); + assert_eq!(fs::read(output).unwrap(), complete); + } + + #[test] + fn refuses_partial_repair_when_a_required_file_is_not_covered() { + let dir = tempfile::tempdir().unwrap(); + let parity = tempfile::tempdir().unwrap(); + let complete = b"ABCDEFGH"; + let parity_files = write_fixture(parity.path(), "data.bin", complete, true); + let partial = b"ABCD\0\0\0\0"; + fs::write(dir.path().join("data.bin.part"), partial).unwrap(); + fs::write(dir.path().join("other.bin.part"), b"\0\0\0\0").unwrap(); + let mut request = repair_request(dir.path(), parity_files); + request.data_files.push(Par2InputFile { + manifest_name: "other.bin".into(), + source_path: dir.path().join("other.bin.part"), + output_path: dir.path().join("other.bin"), + expected_size: Some(4), + }); + request + .required_incomplete_names + .insert("other.bin".to_string()); + + let error = verify_or_repair(&request).unwrap_err(); + + assert!(error + .to_string() + .contains("covers incomplete files other.bin")); + assert_eq!(fs::read(dir.path().join("data.bin.part")).unwrap(), partial); + assert!(!dir.path().join("data.bin").exists()); + } + + #[test] + fn preserves_partial_file_when_recovery_is_insufficient() { + let dir = tempfile::tempdir().unwrap(); + let parity = tempfile::tempdir().unwrap(); + let parity_files = write_fixture(parity.path(), "data.bin", b"ABCDEFGH", false); + let partial = b"ABCD\0\0\0\0"; + fs::write(dir.path().join("data.bin.part"), partial).unwrap(); + + let error = verify_or_repair(&repair_request(dir.path(), parity_files)).unwrap_err(); + + assert!(matches!(error, Par2Error::InsufficientRecovery { .. })); + assert_eq!(fs::read(dir.path().join("data.bin.part")).unwrap(), partial); + } + + #[test] + fn cancellation_stops_the_buffered_link_fallback_before_creating_output() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("source.bin"); + let destination = dir.path().join("staged.bin"); + fs::write(&source, vec![1u8; SPARSE_COPY_BUFFER_BYTES * 2]).unwrap(); + let cancel = CancellationToken::new(); + cancel.cancel(); + + let error = copy_file_with_cancel(&source, &destination, Some(&cancel)).unwrap_err(); + + assert!(matches!(error, Par2Error::Cancelled)); + assert!(!destination.exists()); + } + + #[test] + fn cancellation_after_a_buffer_write_removes_the_partial_output() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("source.bin"); + let destination = dir.path().join("staged.bin"); + fs::write(&source, vec![1u8; SPARSE_COPY_BUFFER_BYTES * 2]).unwrap(); + let cancel = CancellationToken::new(); + let cancel_after_first_write = || cancel.cancel(); + let error = copy_file_with_cancel_inner( + &source, + &destination, + Some(&cancel), + Some(&cancel_after_first_write), + ) + .unwrap_err(); + + assert!(matches!(error, Par2Error::Cancelled)); + assert!(!destination.exists()); + } + + #[test] + fn pre_cancelled_par2_verification_returns_cancelled() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.bin"); + let size = 1; + fs::write(&path, vec![7u8; size]).unwrap(); + let file_id = [1u8; 16]; + let file_set = rust_par2::Par2FileSet { + recovery_set_id: [2u8; 16], + slice_size: size as u64, + file_order: vec![file_id], + files: HashMap::from([( + file_id, + rust_par2::Par2File { + file_id, + hash: [0u8; 16], + hash_16k: [0u8; 16], + size: size as u64, + filename: "data.bin".into(), + slices: Vec::new(), + }, + )]), + recovery_block_count: 0, + creator: None, + }; + let cancel = CancellationToken::new(); + cancel.cancel(); + + let error = verify_with_cancel(&file_set, dir.path(), Some(&cancel)).unwrap_err(); + + assert!(matches!(error, Par2Error::Cancelled)); + } + + #[test] + fn cancellation_during_par2_hashing_is_observed_between_chunks() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.bin"); + let size = VERIFY_HASH_BUFFER_BYTES * 2; + fs::write(&path, vec![7u8; size as usize]).unwrap(); + let file_id = [1u8; 16]; + let file_set = rust_par2::Par2FileSet { + recovery_set_id: [2u8; 16], + slice_size: size, + file_order: vec![file_id], + files: HashMap::from([( + file_id, + rust_par2::Par2File { + file_id, + hash: [0u8; 16], + hash_16k: [0u8; 16], + size, + filename: "data.bin".into(), + slices: Vec::new(), + }, + )]), + recovery_block_count: 0, + creator: None, + }; + let cancel = CancellationToken::new(); + let cancel_for_hook = cancel.clone(); + let mut hook = move || cancel_for_hook.cancel(); + let error = + verify_with_cancel_hook(&file_set, dir.path(), Some(&cancel), &mut hook).unwrap_err(); + + assert!(matches!(error, Par2Error::Cancelled)); + } + + #[test] + fn skips_an_index_that_references_a_missing_nzb_output() { + let input_dir = tempfile::tempdir().unwrap(); + let bad_dir = tempfile::tempdir().unwrap(); + let good_dir = tempfile::tempdir().unwrap(); + let data = b"ABCDEFGH"; + let source = input_dir.path().join("data.bin.part"); + fs::write(&source, data).unwrap(); + let bad = write_fixture(bad_dir.path(), "missing.bin", data, false); + let good = write_fixture(good_dir.path(), "data.bin", data, false); + let inputs = vec![Par2InputFile { + manifest_name: "data.bin".into(), + source_path: source, + output_path: input_dir.path().join("data.bin"), + expected_size: Some(data.len() as u64), + }]; + let data_by_name = validate_input_files(&inputs, platform_limits()).unwrap(); + let parity = validate_parity_files(&[bad[0].clone(), good[0].clone()]).unwrap(); + + let (file_set, _) = parse_index( + &parity.files, + &data_by_name, + &BTreeSet::new(), + platform_limits(), + ) + .unwrap(); + + assert_eq!(file_set.files.len(), 1); + assert_eq!(file_set.files.values().next().unwrap().filename, "data.bin"); + } + + #[test] + fn rejects_unsafe_par2_filenames_before_staging() { + let dir = tempfile::tempdir().unwrap(); + let parity = tempfile::tempdir().unwrap(); + let parity_files = write_fixture(parity.path(), "../escape.bin", b"ABCDEFGH", true); + fs::write(dir.path().join("data.bin.part"), b"ABCD\0\0\0\0").unwrap(); + + let error = verify_or_repair(&repair_request(dir.path(), parity_files)).unwrap_err(); + + assert!(matches!(error, Par2Error::UnsafePath(_))); + } + + #[test] + fn rejects_malformed_par2_before_calling_the_library() { + let dir = tempfile::tempdir().unwrap(); + let bad = dir.path().join("bad.par2"); + fs::write(&bad, b"not a parity file").unwrap(); + fs::write(dir.path().join("data.bin.part"), b"ABCD\0\0\0\0").unwrap(); + + let error = verify_or_repair(&repair_request(dir.path(), vec![bad])).unwrap_err(); + + assert!(matches!(error, Par2Error::Malformed(_))); + } + + #[test] + fn rejects_par2_packet_with_a_bad_checksum() { + let dir = tempfile::tempdir().unwrap(); + let parity = tempfile::tempdir().unwrap(); + let parity_files = write_fixture(parity.path(), "data.bin", b"ABCDEFGH", true); + let index = &parity_files[0]; + let mut bytes = fs::read(index).unwrap(); + let last = bytes.len() - 1; + bytes[last] ^= 1; + fs::write(index, bytes).unwrap(); + fs::write(dir.path().join("data.bin.part"), b"ABCD\0\0\0\0").unwrap(); + + let error = verify_or_repair(&repair_request(dir.path(), parity_files)).unwrap_err(); + + assert!(matches!(error, Par2Error::Malformed(_))); + } + + #[test] + fn counts_parity_and_source_data_in_one_task_budget() { + let request = Par2RepairRequest { + destination: PathBuf::from("unused-destination"), + data_files: vec![Par2InputFile { + manifest_name: "data.bin".into(), + source_path: PathBuf::from("unused.part"), + output_path: PathBuf::from("unused"), + expected_size: Some(8), + }], + parity_files: vec![PathBuf::from("sample.par2")], + required_incomplete_names: BTreeSet::new(), + limits: test_limits(10), + active_started_at: None, + active_elapsed_before_repair: None, + }; + + let error = validate_task_budget( + &request, + SourceStats { bytes: 8 }, + ParityStats { + bytes: 3, + packets: 1, + recovery_blocks: 0, + }, + ) + .unwrap_err(); + + assert!(matches!(error, Par2Error::Limits(_))); + } + + #[test] + fn rejects_repair_sets_that_would_create_an_unbounded_matrix() { + let verification = rust_par2::VerifyResult { + intact: Vec::new(), + damaged: vec![rust_par2::DamagedFile { + filename: "data.bin".into(), + size: 2_050, + damaged_block_count: MAX_PAR2_REPAIR_BLOCKS + 1, + total_block_count: MAX_PAR2_REPAIR_BLOCKS + 1, + damaged_block_indices: (0..=MAX_PAR2_REPAIR_BLOCKS).collect(), + }], + missing: Vec::new(), + recovery_blocks_available: MAX_PAR2_REPAIR_BLOCKS + 1, + repair_possible: true, + }; + let file_set = rust_par2::Par2FileSet { + recovery_set_id: [0; 16], + slice_size: 2, + file_order: Vec::new(), + files: HashMap::new(), + recovery_block_count: 0, + creator: None, + }; + + let error = validate_repair_resources(&verification, &file_set).unwrap_err(); + + assert!(matches!(error, Par2Error::Limits(_))); + } + + #[test] + fn rejects_excess_recovery_packets_before_parsing_with_rust_par2() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("too-many-recovery.par2"); + let mut bytes = Vec::new(); + for exponent in 0..=MAX_PAR2_RECOVERY_BLOCKS { + bytes.extend_from_slice(&build_packet( + [0x5a; 16], + TYPE_RECOVERY, + &exponent.to_le_bytes(), + )); + } + fs::write(&path, bytes).unwrap(); + + let error = validate_parity_files(&[path]).unwrap_err(); + + assert!(matches!(error, Par2Error::Limits(_))); + } + + #[test] + fn rejects_oversized_verification_buffers_before_verifying_files() { + let file_set = rust_par2::Par2FileSet { + recovery_set_id: [0; 16], + slice_size: repair_memory_limit(), + file_order: Vec::new(), + files: HashMap::new(), + recovery_block_count: 0, + creator: None, + }; + + let error = validate_verification_resources(&file_set).unwrap_err(); + + assert!(matches!(error, Par2Error::Limits(_))); + } + + #[test] + fn rejects_source_sizes_that_rust_par2_cannot_address_on_32_bit_targets() { + let error = + validate_rust_par2_addressability(u64::from(u32::MAX) + 1, 4, u64::from(u32::MAX)) + .unwrap_err(); + + assert!(matches!(error, Par2Error::Limits(_))); + } + + #[test] + fn rejects_case_insensitive_nzb_input_collisions_before_staging() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("source.part"); + fs::write(&source, b"data").unwrap(); + let inputs = vec![ + Par2InputFile { + manifest_name: "Data.bin".into(), + source_path: source.clone(), + output_path: dir.path().join("Data.bin"), + expected_size: Some(4), + }, + Par2InputFile { + manifest_name: "data.bin".into(), + source_path: source, + output_path: dir.path().join("data.bin"), + expected_size: Some(4), + }, + ]; + + let error = validate_input_files(&inputs, test_limits(10)).unwrap_err(); + + assert!(matches!(error, Par2Error::Malformed(_))); + } + + #[test] + fn rolls_back_previous_outputs_when_a_later_promotion_fails() { + let dir = tempfile::tempdir().unwrap(); + let stage = dir.path().join("stage"); + fs::create_dir(&stage).unwrap(); + fs::write(stage.join("a.bin"), b"a").unwrap(); + + let input_a = Par2InputFile { + manifest_name: "a.bin".into(), + source_path: dir.path().join("a.bin.part"), + output_path: dir.path().join("a.bin"), + expected_size: Some(1), + }; + let input_b = Par2InputFile { + manifest_name: "b.bin".into(), + source_path: dir.path().join("b.bin.part"), + output_path: dir.path().join("b.bin"), + expected_size: Some(1), + }; + let inputs = HashMap::from([ + (input_a.manifest_name.clone(), &input_a), + (input_b.manifest_name.clone(), &input_b), + ]); + let names = BTreeSet::from(["a.bin".to_string(), "b.bin".to_string()]); + + assert!(promote_outputs(&stage, &names, &inputs, None).is_err()); + assert!(!dir.path().join("a.bin").exists()); + assert_eq!(fs::read(stage.join("a.bin")).unwrap(), b"a"); + } +} diff --git a/src-tauri/risuko-engine/src/engine/usenet_pipeline.rs b/src-tauri/risuko-engine/src/engine/usenet_pipeline.rs new file mode 100644 index 00000000..bfc751c6 --- /dev/null +++ b/src-tauri/risuko-engine/src/engine/usenet_pipeline.rs @@ -0,0 +1,1782 @@ +//! Portable Usenet article assembly primitives + +use crate::engine::archive_safety::ArchiveLimits; +use crate::engine::usenet::NzbSegment; +use futures_util::{stream::FuturesUnordered, StreamExt}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio_util::sync::CancellationToken; + +const RESUME_VERSION: u8 = 2; +const HASH_BUFFER_BYTES: usize = 64 * 1024; +const CHECKPOINT_SEGMENT_INTERVAL: usize = 8; +const CHECKPOINT_TIME_INTERVAL: Duration = Duration::from_secs(5); +const FETCH_CONCURRENCY: usize = 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct YencAssemblyLimits { + max_file_bytes: u64, + max_task_bytes: u64, +} + +impl YencAssemblyLimits { + pub fn new(max_file_bytes: u64, max_task_bytes: u64) -> Result { + if max_file_bytes == 0 || max_task_bytes == 0 { + return Err("yEnc assembly limits must be finite and non-zero".into()); + } + Ok(Self { + max_file_bytes, + max_task_bytes, + }) + } + + pub const fn platform_defaults() -> Self { + let archive = if cfg!(target_os = "android") { + ArchiveLimits::android_defaults() + } else { + ArchiveLimits::desktop_defaults() + }; + Self { + max_file_bytes: archive.max_entry_bytes, + max_task_bytes: archive.max_expanded_bytes, + } + } + + pub const fn max_file_bytes(self) -> u64 { + self.max_file_bytes + } + + pub const fn max_task_bytes(self) -> u64 { + self.max_task_bytes + } + + fn validate_file_size(self, size: u64) -> Result<(), String> { + if size > self.max_file_bytes { + return Err(format!( + "yEnc file size {size} exceeds the per-file limit of {} bytes", + self.max_file_bytes + )); + } + Ok(()) + } + + fn reserve_file(self, budget: &mut YencAssemblyBudget, size: u64) -> Result<(), String> { + self.validate_file_size(size)?; + let total = budget + .reserved_bytes + .checked_add(size) + .ok_or_else(|| "yEnc task size overflowed its finite limit".to_string())?; + if total > self.max_task_bytes { + return Err(format!( + "yEnc task size {total} exceeds the task limit of {} bytes", + self.max_task_bytes + )); + } + budget.reserved_bytes = total; + Ok(()) + } +} + +impl Default for YencAssemblyLimits { + fn default() -> Self { + Self::platform_defaults() + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct YencAssemblyBudget { + reserved_bytes: u64, +} + +impl YencAssemblyBudget { + pub const fn reserved_bytes(&self) -> u64 { + self.reserved_bytes + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ResumeSegment { + pub offset: u64, + pub length: u64, + pub sha256: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ResumeSidecar { + pub version: u8, + pub manifest_sha256: String, + #[serde(default)] + pub completed_segments: BTreeSet, + #[serde(default)] + pub segment_receipts: BTreeMap, + pub completed_bytes: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_size: Option, + #[serde(default)] + pub repaired: bool, +} + +impl ResumeSidecar { + pub fn new(manifest_sha256: String) -> Self { + Self { + version: RESUME_VERSION, + manifest_sha256, + completed_segments: BTreeSet::new(), + segment_receipts: BTreeMap::new(), + completed_bytes: 0, + expected_size: None, + repaired: false, + } + } + + pub async fn load(path: &Path, manifest_sha256: &str) -> Result { + let bytes = match tokio::fs::read(path).await { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Self::new(manifest_sha256.to_string())) + } + Err(error) => return Err(format!("read resume metadata: {error}")), + }; + let parsed: Self = match serde_json::from_slice(&bytes) { + Ok(parsed) => parsed, + Err(_) => return Ok(Self::new(manifest_sha256.to_string())), + }; + if parsed.version != RESUME_VERSION || parsed.manifest_sha256 != manifest_sha256 { + return Ok(Self::new(manifest_sha256.to_string())); + } + Ok(parsed) + } + + pub async fn save_atomic(&self, path: &Path) -> Result<(), String> { + let payload = serde_json::to_vec_pretty(self) + .map_err(|error| format!("serialize resume metadata: {error}"))?; + let temp = path.with_extension(format!( + "{}.tmp", + path.extension().and_then(|v| v.to_str()).unwrap_or("json") + )); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| format!("create resume metadata directory: {error}"))?; + } + let mut file = tokio::fs::File::create(&temp) + .await + .map_err(|error| format!("create resume metadata: {error}"))?; + file.write_all(&payload) + .await + .map_err(|error| format!("write resume metadata: {error}"))?; + file.sync_all() + .await + .map_err(|error| format!("flush resume metadata: {error}"))?; + drop(file); + tokio::fs::rename(&temp, path) + .await + .map_err(|error| format!("replace resume metadata: {error}")) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArticleFetchError { + Unavailable(String), + Failed(String), +} + +impl fmt::Display for ArticleFetchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unavailable(message) => write!(formatter, "article unavailable: {message}"), + Self::Failed(message) => formatter.write_str(message), + } + } +} + +pub trait ArticleSource: Send + Sync { + fn fetch<'a>( + &'a self, + message_id: &'a str, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + >; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct YencRange { + pub start: u64, + pub end: u64, +} + +impl YencRange { + fn len(self) -> Option { + self.end.checked_sub(self.start) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedYencPart { + pub data: Vec, + pub range: YencRange, + pub file_size: Option, + pub has_explicit_range: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssemblyReport { + pub output: PathBuf, + pub part_path: PathBuf, + pub sidecar_path: PathBuf, + pub manifest_sha256: String, + pub expected_size: Option, + pub completed_bytes: u64, + pub unavailable_segments: Vec, + pub complete: bool, +} + +impl AssemblyReport { + pub fn source_path(&self) -> &Path { + if self.complete { + &self.output + } else { + &self.part_path + } + } +} + +pub fn partial_path(output: &Path) -> PathBuf { + output.with_extension(format!( + "{}.part", + output + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("download") + )) +} + +pub fn resume_sidecar_path(output: &Path) -> PathBuf { + output.with_extension(format!( + "{}.resume.json", + output + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("part") + )) +} + +pub async fn resume_sidecar_matches( + output: &Path, + segments: &[NzbSegment], +) -> Result { + let sidecar_path = resume_sidecar_path(output); + let bytes = match tokio::fs::read(&sidecar_path).await { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(format!("read resume metadata: {error}")), + }; + let sidecar: ResumeSidecar = match serde_json::from_slice(&bytes) { + Ok(sidecar) => sidecar, + Err(_) => return Ok(false), + }; + Ok(sidecar.version == RESUME_VERSION && sidecar.manifest_sha256 == manifest_sha256(segments)) +} + +pub fn manifest_sha256(segments: &[NzbSegment]) -> String { + let mut hasher = Sha256::new(); + for segment in segments { + hasher.update(segment.number.to_be_bytes()); + hasher.update(segment.bytes.to_be_bytes()); + hasher.update(segment.message_id.as_bytes()); + hasher.update([0]); + } + hex::encode(hasher.finalize()) +} + +pub fn decode_yenc_part(input: &[u8]) -> Result { + let mut begin_header = None; + let mut part_header = None; + let mut end_header = None; + let mut in_payload = false; + let mut output = Vec::new(); + + for line in input + .split(|byte| *byte == b'\r' || *byte == b'\n') + .filter(|line| !line.is_empty()) + { + if !in_payload { + if line.starts_with(b"=ybegin ") { + begin_header = Some(parse_yenc_header(line, "=ybegin")?); + in_payload = true; + } + continue; + } + if line.starts_with(b"=ypart ") { + if part_header.is_some() { + return Err("multiple =ypart headers in one yEnc article".into()); + } + part_header = Some(parse_yenc_header(line, "=ypart")?); + continue; + } + if line.starts_with(b"=yend ") { + end_header = Some(parse_yenc_header(line, "=yend")?); + break; + } + decode_yenc_line(line, &mut output)?; + } + + let begin_header = begin_header.ok_or_else(|| "article is not yEnc encoded".to_string())?; + let end_header = end_header.ok_or_else(|| "yEnc article has no =yend header".to_string())?; + let declared_total = header_u64(&begin_header, "size")?; + let declared_part_size = header_u64(&end_header, "size")? + .ok_or_else(|| "yEnc =yend header has no size".to_string())?; + if declared_part_size != output.len() as u64 { + return Err(format!( + "yEnc size mismatch: expected {declared_part_size}, got {}", + output.len() + )); + } + if let Some(expected_crc) = end_header.get("pcrc32") { + validate_crc32(&output, expected_crc, "pcrc32")?; + } + + let multipart = begin_header.contains_key("part") + || begin_header.contains_key("total") + || part_header.is_some(); + let (range, has_explicit_range) = match part_header { + Some(header) => { + let begin = header_u64(&header, "begin")? + .ok_or_else(|| "yEnc =ypart header has no begin".to_string())?; + let end_inclusive = header_u64(&header, "end")? + .ok_or_else(|| "yEnc =ypart header has no end".to_string())?; + if begin == 0 || end_inclusive < begin { + return Err("invalid yEnc =ypart range".into()); + } + let range = YencRange { + start: begin - 1, + end: end_inclusive, + }; + if range.len() != Some(output.len() as u64) { + return Err(format!( + "yEnc =ypart range does not match decoded payload of {} bytes", + output.len() + )); + } + (range, true) + } + None if multipart => return Err("multipart yEnc article is missing =ypart".into()), + None => ( + YencRange { + start: 0, + end: output.len() as u64, + }, + false, + ), + }; + if multipart && declared_total.is_none() { + return Err("multipart yEnc article has no declared total size".into()); + } + if let Some(size) = declared_total { + if range.end > size { + return Err("yEnc part exceeds declared file size".into()); + } + if !has_explicit_range && size != output.len() as u64 { + return Err("single-part yEnc size does not match decoded payload".into()); + } + if range.start == 0 && range.end == size { + if let Some(expected_crc) = end_header.get("crc32") { + validate_crc32(&output, expected_crc, "crc32")?; + } + } + } + + Ok(DecodedYencPart { + data: output, + range, + file_size: declared_total, + has_explicit_range, + }) +} + +pub fn decode_yenc(input: &[u8]) -> Result, String> { + Ok(decode_yenc_part(input)?.data) +} + +fn parse_yenc_header(line: &[u8], prefix: &str) -> Result, String> { + let text = std::str::from_utf8(line).map_err(|_| "yEnc header is not ASCII".to_string())?; + let remainder = text + .strip_prefix(prefix) + .ok_or_else(|| "invalid yEnc header".to_string())?; + let mut fields = BTreeMap::new(); + for token in remainder.split_ascii_whitespace() { + let Some((key, value)) = token.split_once('=') else { + continue; + }; + if key.is_empty() || value.is_empty() { + return Err("invalid yEnc header field".into()); + } + fields.insert(key.to_ascii_lowercase(), value.to_string()); + } + Ok(fields) +} + +fn header_u64(header: &BTreeMap, key: &str) -> Result, String> { + header + .get(key) + .map(|value| { + value + .parse::() + .map_err(|_| format!("invalid yEnc {key} value")) + }) + .transpose() +} + +fn validate_crc32(data: &[u8], encoded: &str, field: &str) -> Result<(), String> { + if encoded.len() != 8 || !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!("invalid yEnc {field}")); + } + let expected = u32::from_str_radix(encoded, 16).map_err(|_| format!("invalid yEnc {field}"))?; + let actual = crc32(data); + if actual != expected { + return Err(format!( + "yEnc {field} mismatch: expected {expected:08x}, got {actual:08x}" + )); + } + Ok(()) +} + +fn decode_yenc_line(line: &[u8], output: &mut Vec) -> Result<(), String> { + crate::engine::archive_pipeline::decode_yenc_line(line, output) + .map_err(|error| error.to_string()) +} + +pub(crate) fn crc32(bytes: &[u8]) -> u32 { + let mut crc = 0xffff_ffffu32; + for byte in bytes { + crc ^= *byte as u32; + for _ in 0..8 { + crc = if crc & 1 != 0 { + (crc >> 1) ^ 0xedb8_8320 + } else { + crc >> 1 + }; + } + } + !crc +} + +async fn validate_existing_receipts( + part_path: &Path, + sidecar: &mut ResumeSidecar, + limits: YencAssemblyLimits, +) -> Result<(), String> { + let metadata = match tokio::fs::metadata(part_path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + sidecar.completed_segments.clear(); + sidecar.segment_receipts.clear(); + sidecar.completed_bytes = 0; + return Ok(()); + } + Err(error) => return Err(format!("read assembled part metadata: {error}")), + }; + if !metadata.is_file() { + return Err("assembled part is not a regular file".into()); + } + limits.validate_file_size(metadata.len())?; + let mut file = tokio::fs::File::open(part_path) + .await + .map_err(|error| format!("open assembled part for validation: {error}"))?; + let mut valid = BTreeMap::new(); + for (&number, receipt) in &sidecar.segment_receipts { + let Some(range) = receipt_range(receipt) else { + continue; + }; + if range.end > metadata.len() { + continue; + } + if hash_file_range(&mut file, receipt.offset, receipt.length).await? == receipt.sha256 { + valid.insert(number, receipt.clone()); + } + } + sidecar.completed_segments = valid.keys().copied().collect(); + sidecar.completed_bytes = completed_receipt_bytes(&valid)?; + sidecar.segment_receipts = valid; + Ok(()) +} + +async fn hash_file_range( + file: &mut tokio::fs::File, + offset: u64, + length: u64, +) -> Result { + file.seek(std::io::SeekFrom::Start(offset)) + .await + .map_err(|error| format!("seek assembled part for validation: {error}"))?; + let mut hasher = Sha256::new(); + let mut remaining = length; + let mut buffer = vec![0u8; HASH_BUFFER_BYTES]; + while remaining > 0 { + let wanted = remaining.min(buffer.len() as u64) as usize; + file.read_exact(&mut buffer[..wanted]) + .await + .map_err(|error| format!("read assembled part for validation: {error}"))?; + hasher.update(&buffer[..wanted]); + remaining -= wanted as u64; + } + Ok(hex::encode(hasher.finalize())) +} + +fn ranges_overlap(left: YencRange, right: YencRange) -> bool { + left.start < right.end && right.start < left.end +} + +fn receipt_range(receipt: &ResumeSegment) -> Option { + receipt + .offset + .checked_add(receipt.length) + .map(|end| YencRange { + start: receipt.offset, + end, + }) +} + +fn completed_receipt_bytes(receipts: &BTreeMap) -> Result { + receipts.values().try_fold(0u64, |total, receipt| { + total + .checked_add(receipt.length) + .ok_or_else(|| "resume receipt byte count overflowed".to_string()) + }) +} + +fn has_complete_receipt_coverage( + segments: &[NzbSegment], + sidecar: &ResumeSidecar, + file_len: u64, +) -> bool { + let Some(expected_size) = sidecar.expected_size else { + return false; + }; + if file_len != expected_size + || sidecar.completed_segments.len() != segments.len() + || sidecar.segment_receipts.len() != segments.len() + || !segments.iter().all(|segment| { + sidecar.completed_segments.contains(&segment.number) + && sidecar.segment_receipts.contains_key(&segment.number) + }) + { + return false; + } + + let mut ranges = Vec::with_capacity(sidecar.segment_receipts.len()); + for receipt in sidecar.segment_receipts.values() { + let Some(range) = receipt_range(receipt) else { + return false; + }; + ranges.push(range); + } + ranges.sort_unstable_by_key(|range| range.start); + let mut covered_to = 0u64; + for range in ranges { + if range.start != covered_to { + return false; + } + covered_to = range.end; + } + covered_to == expected_size +} + +fn assembly_report( + output: &Path, + part_path: &Path, + sidecar_path: &Path, + manifest_sha256: &str, + sidecar: &ResumeSidecar, + unavailable_segments: Vec, + complete: bool, +) -> AssemblyReport { + AssemblyReport { + output: output.to_path_buf(), + part_path: part_path.to_path_buf(), + sidecar_path: sidecar_path.to_path_buf(), + manifest_sha256: manifest_sha256.to_string(), + expected_size: sidecar.expected_size, + completed_bytes: sidecar.completed_bytes, + unavailable_segments, + complete, + } +} + +async fn resize_assembled_part( + file: &mut tokio::fs::File, + expected_size: u64, + limits: YencAssemblyLimits, +) -> Result<(), String> { + limits.validate_file_size(expected_size)?; + file.set_len(expected_size) + .await + .map_err(|error| format!("size assembled part: {error}")) +} + +fn validate_decoded_part( + decoded: &DecodedYencPart, + limits: YencAssemblyLimits, +) -> Result { + let length = decoded + .range + .len() + .ok_or_else(|| "yEnc part range is invalid".to_string())?; + if length != decoded.data.len() as u64 { + return Err("yEnc part range does not match the decoded payload".into()); + } + limits.validate_file_size(decoded.range.end)?; + if let Some(size) = decoded.file_size { + limits.validate_file_size(size)?; + if decoded.range.end > size { + return Err("yEnc part exceeds the declared file size".into()); + } + } + Ok(length) +} + +/// Assemble all available articles and retain holes for unavailable segments +pub async fn assemble_file_with_report( + output: &Path, + segments: &[NzbSegment], + source: &S, + cancel: &CancellationToken, + progress: Option<&AtomicU64>, +) -> Result { + let mut budget = YencAssemblyBudget::default(); + assemble_file_with_report_with_limits( + output, + segments, + source, + cancel, + progress, + YencAssemblyLimits::default(), + &mut budget, + ) + .await +} + +pub async fn assemble_file_with_report_with_limits( + output: &Path, + segments: &[NzbSegment], + source: &S, + cancel: &CancellationToken, + progress: Option<&AtomicU64>, + limits: YencAssemblyLimits, + budget: &mut YencAssemblyBudget, +) -> Result { + assemble_file_with_report_with_limits_at_offset( + output, segments, source, cancel, progress, 0, limits, budget, + ) + .await +} + +/// Assemble articles while publishing progress relative to the whole task +pub(crate) async fn assemble_file_with_report_with_limits_at_offset( + output: &Path, + segments: &[NzbSegment], + source: &S, + cancel: &CancellationToken, + progress: Option<&AtomicU64>, + progress_base: u64, + limits: YencAssemblyLimits, + budget: &mut YencAssemblyBudget, +) -> Result { + if segments.is_empty() { + return Err("cannot assemble a file with no segments".into()); + } + let mut ordered = segments.to_vec(); + ordered.sort_by_key(|segment| segment.number); + if ordered + .windows(2) + .any(|pair| pair[0].number == pair[1].number) + { + return Err("NZB file contains duplicate segment numbers".into()); + } + let fingerprint = manifest_sha256(&ordered); + let sidecar_path = resume_sidecar_path(output); + let part_path = partial_path(output); + let mut sidecar = ResumeSidecar::load(&sidecar_path, &fingerprint).await?; + let mut size_reserved = false; + if let Some(size) = sidecar.expected_size { + limits.reserve_file(budget, size)?; + size_reserved = true; + } + + match tokio::fs::metadata(output).await { + Ok(metadata) => { + if !metadata.is_file() { + return Err("finalized Usenet output is not a regular file".into()); + } + limits.validate_file_size(metadata.len())?; + if sidecar.repaired { + if sidecar.expected_size != Some(metadata.len()) { + return Err("repaired Usenet output does not match its resume metadata".into()); + } + publish_assembly_progress(progress, progress_base, total_article_bytes(&ordered)?); + return Ok(assembly_report( + output, + &part_path, + &sidecar_path, + &fingerprint, + &sidecar, + Vec::new(), + true, + )); + } + + validate_existing_receipts(output, &mut sidecar, limits).await?; + if has_complete_receipt_coverage(&ordered, &sidecar, metadata.len()) { + publish_assembly_progress( + progress, + progress_base, + completed_article_bytes(&ordered, &sidecar.completed_segments)?, + ); + return Ok(assembly_report( + output, + &part_path, + &sidecar_path, + &fingerprint, + &sidecar, + Vec::new(), + true, + )); + } + match tokio::fs::symlink_metadata(&part_path).await { + Ok(part_metadata) if part_metadata.file_type().is_file() => { + tokio::fs::remove_file(output) + .await + .map_err(|error| format!("discard uncommitted Usenet output: {error}"))?; + } + Ok(_) => return Err("assembled part is not a regular file".into()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err( + "finalized Usenet output does not match its resume receipts and has no resumable part" + .into(), + ); + } + Err(error) => return Err(format!("read assembled part metadata: {error}")), + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("read finalized Usenet output metadata: {error}")), + } + + sidecar.repaired = false; + if let Some(parent) = part_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| format!("create output directory: {error}"))?; + } + validate_existing_receipts(&part_path, &mut sidecar, limits).await?; + if !size_reserved { + if let Some(size) = sidecar.expected_size { + limits.reserve_file(budget, size)?; + } + } + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&part_path) + .await + .map_err(|error| format!("open assembled part: {error}"))?; + if let Some(size) = sidecar.expected_size { + resize_assembled_part(&mut file, size, limits).await?; + } + let mut resized_expected_size = sidecar.expected_size; + let mut segments_since_checkpoint = 0usize; + let mut last_checkpoint = Instant::now(); + let mut completed_receipt_bytes = sidecar.completed_bytes; + let mut completed_article_bytes = + completed_article_bytes(&ordered, &sidecar.completed_segments)?; + publish_assembly_progress(progress, progress_base, completed_article_bytes); + + let mut unavailable_segments = Vec::new(); + + async fn checkpoint( + file: &mut tokio::fs::File, + sidecar: &ResumeSidecar, + sidecar_path: &Path, + ) -> Result<(), String> { + file.sync_data() + .await + .map_err(|error| format!("flush assembled part: {error}"))?; + sidecar.save_atomic(sidecar_path).await + } + + macro_rules! return_after_checkpoint { + ($error:expr) => {{ + checkpoint(&mut file, &sidecar, &sidecar_path).await?; + return Err($error); + }}; + } + + // Keep network fetches in flight while yielding decoded parts in manifest + // order. The fixed batch size bounds completed decoded payloads without + // trusting potentially inaccurate manifest byte counts. + let pending_segments = ordered + .iter() + .filter(|segment| !sidecar.completed_segments.contains(&segment.number)) + .collect::>(); + let mut batch_start = 0; + while batch_start < pending_segments.len() { + let batch_end = (batch_start + FETCH_CONCURRENCY).min(pending_segments.len()); + let batch = &pending_segments[batch_start..batch_end]; + let mut fetches = FuturesUnordered::new(); + for (order, segment) in batch.iter().enumerate() { + let segment = (*segment).clone(); + let message_id = segment.message_id.clone(); + fetches.push(async move { + let result = source.fetch(&message_id).await; + (order, segment, result) + }); + } + let mut fetched = Vec::with_capacity(batch.len()); + while let Some(result) = fetches.next().await { + fetched.push(result); + } + fetched.sort_by_key(|(order, _, _)| *order); + for (_, segment, fetch_result) in fetched { + if cancel.is_cancelled() { + return_after_checkpoint!("Download cancelled".into()); + } + let decoded = match fetch_result { + Ok(decoded) => decoded, + Err(ArticleFetchError::Unavailable(_)) => { + unavailable_segments.push(segment.number); + continue; + } + Err(error) => return_after_checkpoint!(error.to_string()), + }; + if ordered.len() > 1 && !decoded.has_explicit_range { + return_after_checkpoint!( + "multipart NZB file has yEnc data without =ypart offsets".into() + ); + } + if ordered.len() > 1 && decoded.file_size.is_none() { + return_after_checkpoint!( + "multipart NZB file has yEnc data without a declared total size".into() + ); + } + let receipt_length = match validate_decoded_part(&decoded, limits) { + Ok(length) => length, + Err(error) => return_after_checkpoint!(error), + }; + let expected_size = match (sidecar.expected_size, decoded.file_size) { + (Some(existing), Some(size)) if existing != size => { + return_after_checkpoint!("yEnc parts disagree about total file size".into()) + } + (Some(existing), _) => existing, + (None, Some(size)) => { + sidecar.expected_size = Some(size); + if let Err(error) = limits.reserve_file(budget, size) { + return_after_checkpoint!(error); + } + size + } + (None, None) if ordered.len() == 1 && decoded.range.start == 0 => { + let size = decoded.range.end; + sidecar.expected_size = Some(size); + if let Err(error) = limits.reserve_file(budget, size) { + return_after_checkpoint!(error); + } + size + } + (None, None) => { + return_after_checkpoint!( + "yEnc file size is ambiguous without a declared total size".into() + ) + } + }; + if decoded.range.end > expected_size { + return_after_checkpoint!("yEnc part exceeds the assembled file size".into()); + } + let candidate_receipt = ResumeSegment { + offset: decoded.range.start, + length: receipt_length, + sha256: hex::encode(Sha256::digest(&decoded.data)), + }; + if sidecar.segment_receipts.iter().any(|(&number, receipt)| { + number != segment.number + && match receipt_range(receipt) { + Some(existing_range) => ranges_overlap(decoded.range, existing_range), + None => true, + } + }) { + return_after_checkpoint!("yEnc part overlaps a completed segment".into()); + } + if resized_expected_size != Some(expected_size) { + if let Err(error) = resize_assembled_part(&mut file, expected_size, limits).await { + return_after_checkpoint!(error); + } + resized_expected_size = Some(expected_size); + } + if let Err(error) = file + .seek(std::io::SeekFrom::Start(decoded.range.start)) + .await + { + return_after_checkpoint!(format!("seek assembled part: {error}")); + } + if let Err(error) = file.write_all(&decoded.data).await { + return_after_checkpoint!(format!("write assembled part: {error}")); + } + sidecar.completed_segments.insert(segment.number); + sidecar + .segment_receipts + .insert(segment.number, candidate_receipt.clone()); + completed_receipt_bytes = + match completed_receipt_bytes.checked_add(candidate_receipt.length) { + Some(bytes) => bytes, + None => return_after_checkpoint!("resume receipt byte count overflowed".into()), + }; + completed_article_bytes = match completed_article_bytes.checked_add(segment.bytes) { + Some(bytes) => bytes, + None => return_after_checkpoint!("NZB article byte count overflowed".into()), + }; + sidecar.completed_bytes = completed_receipt_bytes; + segments_since_checkpoint = segments_since_checkpoint.saturating_add(1); + if segments_since_checkpoint >= CHECKPOINT_SEGMENT_INTERVAL + || last_checkpoint.elapsed() >= CHECKPOINT_TIME_INTERVAL + { + checkpoint(&mut file, &sidecar, &sidecar_path).await?; + segments_since_checkpoint = 0; + last_checkpoint = Instant::now(); + } + publish_assembly_progress(progress, progress_base, completed_article_bytes); + } + batch_start = batch_end; + } + checkpoint(&mut file, &sidecar, &sidecar_path).await?; + file.sync_all() + .await + .map_err(|error| format!("flush assembled part: {error}"))?; + drop(file); + + let part_len = tokio::fs::metadata(&part_path) + .await + .map_err(|error| format!("read assembled part metadata: {error}"))? + .len(); + let complete = has_complete_receipt_coverage(&ordered, &sidecar, part_len); + if complete { + tokio::fs::rename(&part_path, output) + .await + .map_err(|error| format!("finalize assembled file: {error}"))?; + } + Ok(assembly_report( + output, + &part_path, + &sidecar_path, + &fingerprint, + &sidecar, + unavailable_segments, + complete, + )) +} + +fn publish_assembly_progress( + progress: Option<&AtomicU64>, + progress_base: u64, + file_completed: u64, +) { + if let Some(counter) = progress { + counter.store( + progress_base.saturating_add(file_completed), + Ordering::Relaxed, + ); + } +} + +fn total_article_bytes(segments: &[NzbSegment]) -> Result { + segments.iter().try_fold(0u64, |total, segment| { + total + .checked_add(segment.bytes) + .ok_or_else(|| "NZB article byte count overflowed".to_string()) + }) +} + +fn completed_article_bytes( + segments: &[NzbSegment], + completed_segments: &BTreeSet, +) -> Result { + segments + .iter() + .filter(|segment| completed_segments.contains(&segment.number)) + .try_fold(0u64, |total, segment| { + total + .checked_add(segment.bytes) + .ok_or_else(|| "NZB article byte count overflowed".to_string()) + }) +} + +/// Compatibility wrapper for callers that require every article to be present +pub async fn assemble_file( + output: &Path, + segments: &[NzbSegment], + source: &S, + cancel: &CancellationToken, + progress: Option<&AtomicU64>, +) -> Result { + let report = assemble_file_with_report(output, segments, source, cancel, progress).await?; + if !report.complete { + return Err(format!( + "article unavailable for NZB segments {:?}", + report.unavailable_segments + )); + } + Ok(report.output) +} + +/// Persist that a verified PAR2 repair promoted `report.output` successfully +pub async fn mark_par2_repaired(report: &AssemblyReport) -> Result<(), String> { + let metadata = tokio::fs::metadata(&report.output) + .await + .map_err(|error| format!("read repaired output metadata: {error}"))?; + if !metadata.is_file() { + return Err("repaired output is not a regular file".into()); + } + let mut sidecar = ResumeSidecar::load(&report.sidecar_path, &report.manifest_sha256).await?; + sidecar.expected_size = Some(metadata.len()); + sidecar.completed_segments.clear(); + sidecar.segment_receipts.clear(); + sidecar.completed_bytes = metadata.len(); + sidecar.repaired = true; + sidecar.save_atomic(&report.sidecar_path).await +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + use std::sync::Arc; + + #[derive(Default)] + struct FakeSource { + responses: BTreeMap, ArticleFetchError>>, + } + + impl ArticleSource for FakeSource { + fn fetch<'a>( + &'a self, + message_id: &'a str, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + let response = + self.responses.get(message_id).cloned().unwrap_or_else(|| { + Err(ArticleFetchError::Failed("unexpected message ID".into())) + }); + Box::pin(async move { + response.and_then(|article| { + decode_yenc_part(&article).map_err(ArticleFetchError::Failed) + }) + }) + } + } + + struct StaticDecodedSource { + part: DecodedYencPart, + } + + impl ArticleSource for StaticDecodedSource { + fn fetch<'a>( + &'a self, + _message_id: &'a str, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + let part = self.part.clone(); + Box::pin(async move { Ok(part) }) + } + } + + struct ProgressInspectingSource<'progress> { + responses: BTreeMap, ArticleFetchError>>, + progress: &'progress AtomicU64, + progress_before_second_fetch: AtomicU64, + } + + impl ArticleSource for ProgressInspectingSource<'_> { + fn fetch<'a>( + &'a self, + message_id: &'a str, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + if message_id == "two" { + self.progress_before_second_fetch + .store(self.progress.load(Ordering::Relaxed), Ordering::Relaxed); + } + let response = + self.responses.get(message_id).cloned().unwrap_or_else(|| { + Err(ArticleFetchError::Failed("unexpected message ID".into())) + }); + Box::pin(async move { + response.and_then(|article| { + decode_yenc_part(&article).map_err(ArticleFetchError::Failed) + }) + }) + } + } + + struct ConcurrencyTrackingSource { + active: Arc, + max_active: Arc, + part_size: u64, + } + + impl ArticleSource for ConcurrencyTrackingSource { + fn fetch<'a>( + &'a self, + message_id: &'a str, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + let index = message_id.parse::().unwrap(); + let active = Arc::clone(&self.active); + let max_active = Arc::clone(&self.max_active); + let part_size = self.part_size; + Box::pin(async move { + let current = active.fetch_add(1, Ordering::Relaxed) + 1; + let mut observed = max_active.load(Ordering::Relaxed); + while current > observed { + match max_active.compare_exchange_weak( + observed, + current, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(next) => observed = next, + } + } + tokio::time::sleep(Duration::from_millis(5)).await; + active.fetch_sub(1, Ordering::Relaxed); + let start = index * part_size; + Ok(DecodedYencPart { + data: vec![b'A' + index as u8; part_size as usize], + range: YencRange { + start, + end: start + part_size, + }, + file_size: Some(part_size * 8), + has_explicit_range: true, + }) + }) + } + } + + fn encode_yenc_part(payload: &[u8], begin: u64, total_size: u64) -> Vec { + let end = begin + payload.len() as u64 - 1; + let mut encoded = format!( + "=ybegin part=1 total=3 line=128 size={total_size} name=x\r\n=ypart begin={begin} end={end}\r\n" + ) + .into_bytes(); + for &byte in payload { + let value = byte.wrapping_add(42); + if matches!(value, 0 | 10 | 13 | 61) { + encoded.push(b'='); + encoded.push(value.wrapping_add(64)); + } else { + encoded.push(value); + } + } + encoded.extend_from_slice( + format!( + "\r\n=yend size={} pcrc32={:08x}\r\n", + payload.len(), + crc32(payload) + ) + .as_bytes(), + ); + encoded + } + + #[test] + fn decodes_yenc_escapes() { + let payload = b"a\n=\r\0z"; + let encoded = encode_yenc_part(payload, 1, payload.len() as u64); + assert_eq!(decode_yenc(&encoded).unwrap(), payload); + } + + #[test] + fn rejects_multipart_yenc_without_placement() { + let article = b"=ybegin part=1 total=2 size=2 name=x\r\nkl\r\n=yend size=2\r\n"; + assert!(decode_yenc_part(article).is_err()); + } + + #[test] + fn rejects_multipart_yenc_without_a_declared_total_size() { + let article = b"=ybegin part=1 total=2 line=128 name=x\r\n=ypart begin=1 end=2\r\nkl\r\n=yend size=2\r\n"; + assert!(decode_yenc_part(article).is_err()); + } + + #[test] + fn manifest_is_stable() { + let segments = vec![NzbSegment { + number: 1, + bytes: 3, + message_id: "".into(), + }]; + assert_eq!(manifest_sha256(&segments).len(), 64); + } + + #[tokio::test] + async fn resume_sidecar_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("resume.json"); + let mut state = ResumeSidecar::new("abc".into()); + state.completed_segments.insert(2); + state.segment_receipts.insert( + 2, + ResumeSegment { + offset: 0, + length: 9, + sha256: "x".into(), + }, + ); + state.completed_bytes = 9; + state.save_atomic(&path).await.unwrap(); + assert_eq!(ResumeSidecar::load(&path, "abc").await.unwrap(), state); + assert_eq!( + ResumeSidecar::load(&path, "changed") + .await + .unwrap() + .completed_bytes, + 0 + ); + } + + #[tokio::test] + async fn malformed_resume_metadata_starts_a_fresh_sidecar() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("resume.json"); + tokio::fs::write(&path, b"{truncated").await.unwrap(); + + assert_eq!( + ResumeSidecar::load(&path, "manifest").await.unwrap(), + ResumeSidecar::new("manifest".into()) + ); + } + + #[tokio::test] + async fn keeps_sparse_holes_when_a_middle_article_is_unavailable() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let mut source = FakeSource::default(); + source + .responses + .insert("one".into(), Ok(encode_yenc_part(b"AB", 1, 6))); + source.responses.insert( + "two".into(), + Err(ArticleFetchError::Unavailable("430".into())), + ); + source + .responses + .insert("three".into(), Ok(encode_yenc_part(b"EF", 5, 6))); + let segments = vec![ + NzbSegment { + number: 1, + bytes: 2, + message_id: "one".into(), + }, + NzbSegment { + number: 2, + bytes: 2, + message_id: "two".into(), + }, + NzbSegment { + number: 3, + bytes: 2, + message_id: "three".into(), + }, + ]; + + let report = + assemble_file_with_report(&output, &segments, &source, &CancellationToken::new(), None) + .await + .unwrap(); + + assert!(!report.complete); + assert_eq!(report.unavailable_segments, vec![2]); + assert_eq!( + tokio::fs::read(report.part_path).await.unwrap(), + b"AB\0\0EF" + ); + assert!(!output.exists()); + } + + #[tokio::test] + async fn publishes_nzb_article_bytes_at_the_task_progress_offset() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let progress = AtomicU64::new(7); + let mut responses = BTreeMap::new(); + responses.insert("one".into(), Ok(encode_yenc_part(b"AB", 1, 4))); + responses.insert("two".into(), Ok(encode_yenc_part(b"CD", 3, 4))); + let source = ProgressInspectingSource { + responses, + progress: &progress, + progress_before_second_fetch: AtomicU64::new(0), + }; + let segments = vec![ + NzbSegment { + number: 1, + bytes: 17, + message_id: "one".into(), + }, + NzbSegment { + number: 2, + bytes: 19, + message_id: "two".into(), + }, + ]; + let mut budget = YencAssemblyBudget::default(); + + let report = assemble_file_with_report_with_limits_at_offset( + &output, + &segments, + &source, + &CancellationToken::new(), + Some(&progress), + 7, + YencAssemblyLimits::new(4, 4).unwrap(), + &mut budget, + ) + .await + .unwrap(); + + assert!(report.complete); + assert_eq!( + source.progress_before_second_fetch.load(Ordering::Relaxed), + 7 + ); + assert_eq!(progress.load(Ordering::Relaxed), 43); + } + + #[tokio::test] + async fn bounds_fetches_when_manifest_counts_are_too_small() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + let source = ConcurrencyTrackingSource { + active: Arc::clone(&active), + max_active: Arc::clone(&max_active), + part_size: 1024, + }; + let segments = (0..8) + .map(|index| NzbSegment { + number: index as u32 + 1, + bytes: 1, + message_id: index.to_string(), + }) + .collect::>(); + + let report = + assemble_file_with_report(&output, &segments, &source, &CancellationToken::new(), None) + .await + .unwrap(); + + assert!(report.complete); + assert_eq!(tokio::fs::metadata(&output).await.unwrap().len(), 8 * 1024); + let max_active = max_active.load(Ordering::Relaxed); + assert!(max_active > 1); + assert!(max_active <= FETCH_CONCURRENCY); + } + + #[tokio::test] + async fn restores_durable_article_progress_at_the_task_offset() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let segments = vec![ + NzbSegment { + number: 1, + bytes: 17, + message_id: "one".into(), + }, + NzbSegment { + number: 2, + bytes: 19, + message_id: "two".into(), + }, + ]; + tokio::fs::write(partial_path(&output), b"AB\0\0") + .await + .unwrap(); + let mut sidecar = ResumeSidecar::new(manifest_sha256(&segments)); + sidecar.expected_size = Some(4); + sidecar.completed_segments.insert(1); + sidecar.segment_receipts.insert( + 1, + ResumeSegment { + offset: 0, + length: 2, + sha256: hex::encode(Sha256::digest(b"AB")), + }, + ); + sidecar.completed_bytes = 2; + sidecar + .save_atomic(&resume_sidecar_path(&output)) + .await + .unwrap(); + + let progress = AtomicU64::new(7); + let mut responses = BTreeMap::new(); + responses.insert("two".into(), Ok(encode_yenc_part(b"CD", 3, 4))); + let source = ProgressInspectingSource { + responses, + progress: &progress, + progress_before_second_fetch: AtomicU64::new(0), + }; + let mut budget = YencAssemblyBudget::default(); + + let report = assemble_file_with_report_with_limits_at_offset( + &output, + &segments, + &source, + &CancellationToken::new(), + Some(&progress), + 7, + YencAssemblyLimits::new(4, 4).unwrap(), + &mut budget, + ) + .await + .unwrap(); + + assert!(report.complete); + assert_eq!( + source.progress_before_second_fetch.load(Ordering::Relaxed), + 24 + ); + assert_eq!(progress.load(Ordering::Relaxed), 43); + } + + #[tokio::test] + async fn does_not_finalize_when_every_segment_receipt_leaves_a_sparse_gap() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let mut source = FakeSource::default(); + source + .responses + .insert("one".into(), Ok(encode_yenc_part(b"AB", 1, 6))); + source + .responses + .insert("two".into(), Ok(encode_yenc_part(b"EF", 5, 6))); + let segments = vec![ + NzbSegment { + number: 1, + bytes: 2, + message_id: "one".into(), + }, + NzbSegment { + number: 2, + bytes: 2, + message_id: "two".into(), + }, + ]; + + let report = + assemble_file_with_report(&output, &segments, &source, &CancellationToken::new(), None) + .await + .unwrap(); + + assert!(!report.complete); + assert!(report.unavailable_segments.is_empty()); + assert_eq!( + tokio::fs::read(report.part_path).await.unwrap(), + b"AB\0\0EF" + ); + assert!(!output.exists()); + } + + #[tokio::test] + async fn rejects_an_oversized_declared_file_before_resizing_a_sparse_part() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let source = StaticDecodedSource { + part: DecodedYencPart { + data: b"A".to_vec(), + range: YencRange { start: 0, end: 1 }, + file_size: Some(7), + has_explicit_range: false, + }, + }; + let segments = vec![NzbSegment { + number: 1, + bytes: 1, + message_id: "one".into(), + }]; + let limits = YencAssemblyLimits::new(6, 12).unwrap(); + let mut budget = YencAssemblyBudget::default(); + + let error = assemble_file_with_report_with_limits( + &output, + &segments, + &source, + &CancellationToken::new(), + None, + limits, + &mut budget, + ) + .await + .unwrap_err(); + + assert!(error.contains("per-file limit")); + assert_eq!( + tokio::fs::metadata(partial_path(&output)) + .await + .unwrap() + .len(), + 0 + ); + } + + #[tokio::test] + async fn rejects_an_oversized_yenc_offset_before_resizing_a_sparse_part() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let source = StaticDecodedSource { + part: DecodedYencPart { + data: b"A".to_vec(), + range: YencRange { start: 6, end: 7 }, + file_size: None, + has_explicit_range: true, + }, + }; + let segments = vec![NzbSegment { + number: 1, + bytes: 1, + message_id: "one".into(), + }]; + let limits = YencAssemblyLimits::new(6, 12).unwrap(); + let mut budget = YencAssemblyBudget::default(); + + let error = assemble_file_with_report_with_limits( + &output, + &segments, + &source, + &CancellationToken::new(), + None, + limits, + &mut budget, + ) + .await + .unwrap_err(); + + assert!(error.contains("per-file limit")); + assert_eq!( + tokio::fs::metadata(partial_path(&output)) + .await + .unwrap() + .len(), + 0 + ); + } + + #[tokio::test] + async fn shared_budget_rejects_files_that_exceed_the_task_limit() { + let dir = tempfile::tempdir().unwrap(); + let first_output = dir.path().join("first.bin"); + let second_output = dir.path().join("second.bin"); + let segments = vec![NzbSegment { + number: 1, + bytes: 4, + message_id: "one".into(), + }]; + let limits = YencAssemblyLimits::new(6, 6).unwrap(); + let mut budget = YencAssemblyBudget::default(); + let first_source = StaticDecodedSource { + part: DecodedYencPart { + data: b"ABCD".to_vec(), + range: YencRange { start: 0, end: 4 }, + file_size: Some(4), + has_explicit_range: false, + }, + }; + let second_source = StaticDecodedSource { + part: DecodedYencPart { + data: b"EFGH".to_vec(), + range: YencRange { start: 0, end: 4 }, + file_size: Some(4), + has_explicit_range: false, + }, + }; + + assemble_file_with_report_with_limits( + &first_output, + &segments, + &first_source, + &CancellationToken::new(), + None, + limits, + &mut budget, + ) + .await + .unwrap(); + let error = assemble_file_with_report_with_limits( + &second_output, + &segments, + &second_source, + &CancellationToken::new(), + None, + limits, + &mut budget, + ) + .await + .unwrap_err(); + + assert!(error.contains("task limit")); + assert_eq!(budget.reserved_bytes(), 4); + } + + #[tokio::test] + async fn normally_finalized_output_is_recognized_without_refetching_segments() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let segments = vec![NzbSegment { + number: 1, + bytes: 2, + message_id: "one".into(), + }]; + let mut source = FakeSource::default(); + source + .responses + .insert("one".into(), Ok(encode_yenc_part(b"OK", 1, 2))); + + let initial = + assemble_file_with_report(&output, &segments, &source, &CancellationToken::new(), None) + .await + .unwrap(); + assert!(initial.complete); + + let resumed = assemble_file_with_report( + &output, + &segments, + &FakeSource::default(), + &CancellationToken::new(), + None, + ) + .await + .unwrap(); + + assert!(resumed.complete); + } + + #[tokio::test] + async fn stale_promoted_output_reenters_the_repairable_partial_state() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let segments = vec![NzbSegment { + number: 1, + bytes: 2, + message_id: "one".into(), + }]; + let sidecar = ResumeSidecar::new(manifest_sha256(&segments)); + sidecar + .save_atomic(&resume_sidecar_path(&output)) + .await + .unwrap(); + tokio::fs::write(&output, b"OK").await.unwrap(); + tokio::fs::write(partial_path(&output), b"\0\0") + .await + .unwrap(); + let mut source = FakeSource::default(); + source.responses.insert( + "one".into(), + Err(ArticleFetchError::Unavailable("430".into())), + ); + + let report = + assemble_file_with_report(&output, &segments, &source, &CancellationToken::new(), None) + .await + .unwrap(); + + assert!(!report.complete); + assert_eq!(report.unavailable_segments, vec![1]); + assert!(!output.exists()); + assert_eq!( + tokio::fs::read(partial_path(&output)).await.unwrap(), + b"\0\0" + ); + } + + #[tokio::test] + async fn repaired_output_is_recognized_without_refetching_segments() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let segments = vec![NzbSegment { + number: 1, + bytes: 17, + message_id: "one".into(), + }]; + let initial = AssemblyReport { + output: output.clone(), + part_path: partial_path(&output), + sidecar_path: resume_sidecar_path(&output), + manifest_sha256: manifest_sha256(&segments), + expected_size: Some(2), + completed_bytes: 0, + unavailable_segments: vec![1], + complete: false, + }; + let mut stale = ResumeSidecar::new(initial.manifest_sha256.clone()); + stale.completed_segments.insert(1); + stale.segment_receipts.insert( + 1, + ResumeSegment { + offset: 0, + length: 2, + sha256: hex::encode(Sha256::digest(b"OK")), + }, + ); + stale.save_atomic(&initial.sidecar_path).await.unwrap(); + tokio::fs::write(&output, b"OK").await.unwrap(); + mark_par2_repaired(&initial).await.unwrap(); + let persisted = ResumeSidecar::load(&initial.sidecar_path, &initial.manifest_sha256) + .await + .unwrap(); + assert!(persisted.completed_segments.is_empty()); + assert!(persisted.segment_receipts.is_empty()); + let progress = AtomicU64::new(0); + let report = assemble_file_with_report( + &output, + &segments, + &FakeSource::default(), + &CancellationToken::new(), + Some(&progress), + ) + .await + .unwrap(); + assert!(report.complete); + assert_eq!(progress.load(Ordering::Relaxed), 17); + } +} diff --git a/src-tauri/risuko-engine/src/engine/usenet_transport.rs b/src-tauri/risuko-engine/src/engine/usenet_transport.rs new file mode 100644 index 00000000..2e822274 --- /dev/null +++ b/src-tauri/risuko-engine/src/engine/usenet_transport.rs @@ -0,0 +1,1250 @@ +//! Bounded NNTP transport and provider selection + +use crate::engine::usenet::{UsenetCredentials, UsenetProviderProfile}; +use parking_lot::Mutex; +use rustls::pki_types::ServerName; +use rustls::{ClientConfig, RootCertStore}; +use std::collections::{BTreeMap, HashMap}; +use std::fmt; +use std::io; +use std::net::IpAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::OnceLock; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader, ReadBuf}; +use tokio::net::TcpStream; +use tokio::sync::watch; +use tokio::time::timeout; +use tokio_rustls::TlsConnector; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); +const IO_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_LINE: usize = 64 * 1024; +const MAX_ARTICLE_BYTES: usize = 256 * 1024 * 1024; +const MAX_MULTILINE_LINES: usize = MAX_ARTICLE_BYTES / MAX_LINE; +const HEALTH_COOLDOWN: Duration = Duration::from_secs(60); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailoverClass { + Retryable, + Authentication, + Permanent, +} + +#[derive(Debug, thiserror::Error)] +pub enum NntpError { + #[error("NNTP profile is invalid: {0}")] + InvalidProfile(String), + #[error("plain NNTP is not explicitly enabled for this profile")] + PlainNotAllowed, + #[error("NNTP connection timed out")] + Timeout, + #[error("Download cancelled")] + Cancelled, + #[error("NNTP connection capacity is currently unavailable")] + CapacityUnavailable, + #[error("NNTP I/O error: {0}")] + Io(#[from] io::Error), + #[error("NNTP TLS error: {0}")] + Tls(String), + #[error("NNTP protocol error ({code}): {message}")] + Protocol { code: u16, message: String }, + #[error("NNTP service unavailable ({code}): {message}")] + ServiceUnavailable { code: u16, message: String }, + #[error("NNTP authentication failed ({code}): {message}")] + AuthenticationFailed { code: u16, message: String }, + #[error("NNTP article is unavailable ({code}): {message}")] + ArticleUnavailable { code: u16, message: String }, + #[error("NNTP article has invalid yEnc data: {message}")] + ArticleCorrupt { message: String }, + #[error("NNTP response line exceeds the {MAX_LINE} byte limit")] + ResponseTooLong, + #[error("NNTP article exceeds the {MAX_ARTICLE_BYTES} byte limit")] + ArticleTooLarge, +} + +impl NntpError { + pub fn failover_class(&self) -> FailoverClass { + match self { + Self::Timeout + | Self::Io(_) + | Self::Tls(_) + | Self::ArticleUnavailable { .. } + | Self::ArticleCorrupt { .. } => FailoverClass::Retryable, + Self::ServiceUnavailable { .. } | Self::CapacityUnavailable => FailoverClass::Retryable, + Self::AuthenticationFailed { .. } => FailoverClass::Authentication, + Self::InvalidProfile(_) + | Self::PlainNotAllowed + | Self::Cancelled + | Self::Protocol { .. } + | Self::ResponseTooLong + | Self::ArticleTooLarge => FailoverClass::Permanent, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NntpResponse { + pub code: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NntpCapabilities { + pub capabilities: Vec, +} + +impl NntpCapabilities { + pub fn supports(&self, value: &str) -> bool { + self.capabilities + .iter() + .any(|cap| cap.eq_ignore_ascii_case(value)) + } +} + +trait AsyncReadWrite: AsyncRead + AsyncWrite + Send + Unpin {} +impl AsyncReadWrite for T {} + +struct BoxedStream(Box); + +impl AsyncRead for BoxedStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut *self.0).poll_read(cx, buf) + } +} + +impl AsyncWrite for BoxedStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + Pin::new(&mut *self.0).poll_write(cx, bytes) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut *self.0).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut *self.0).poll_shutdown(cx) + } +} + +pub struct NntpConnection { + reader: BufReader, + profile_id: String, + greeted: bool, +} + +impl fmt::Debug for NntpConnection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NntpConnection") + .field("profile_id", &self.profile_id) + .field("greeted", &self.greeted) + .finish() + } +} + +impl NntpConnection { + pub fn profile_id(&self) -> &str { + &self.profile_id + } + + pub async fn connect( + profile: &UsenetProviderProfile, + credentials: Option, + ) -> Result { + validate_profile(profile)?; + if profile.security_mode == "implicit-tls" { + let mut connection = Self::connect_implicit(profile).await?; + if let Some(credentials) = credentials { + connection.authenticate(credentials).await?; + } + return Ok(connection); + } + let stream = timeout( + CONNECT_TIMEOUT, + TcpStream::connect((profile.host.as_str(), profile.port)), + ) + .await + .map_err(|_| NntpError::Timeout)? + .map_err(NntpError::Io)?; + let mut connection = Self { + reader: BufReader::new(BoxedStream(Box::new(stream))), + profile_id: profile.id.clone(), + greeted: false, + }; + let greeting = connection.read_response().await?; + if greeting.code != 200 && greeting.code != 201 { + return Err(response_error(greeting, "server rejected greeting")); + } + connection.greeted = true; + + if profile.security_mode == "starttls" { + let starttls = connection.command("STARTTLS").await?; + if starttls.code != 382 { + return Err(response_error(starttls, "STARTTLS was not accepted")); + } + connection = connection.upgrade_tls(&profile.host).await?; + } + + if let Some(credentials) = credentials { + connection.authenticate(credentials).await?; + } + Ok(connection) + } + + async fn connect_implicit(profile: &UsenetProviderProfile) -> Result { + let stream = timeout( + CONNECT_TIMEOUT, + TcpStream::connect((profile.host.as_str(), profile.port)), + ) + .await + .map_err(|_| NntpError::Timeout)? + .map_err(NntpError::Io)?; + let tls = timeout( + IO_TIMEOUT, + tls_connector().connect(server_name(&profile.host)?, stream), + ) + .await + .map_err(|_| NntpError::Timeout)? + .map_err(|e| NntpError::Tls(e.to_string()))?; + let mut connection = Self { + reader: BufReader::new(BoxedStream(Box::new(tls))), + profile_id: profile.id.clone(), + greeted: false, + }; + let greeting = connection.read_response().await?; + if greeting.code != 200 && greeting.code != 201 { + return Err(response_error(greeting, "server rejected greeting")); + } + connection.greeted = true; + Ok(connection) + } + + async fn upgrade_tls(mut self, host: &str) -> Result { + if !self.reader.buffer().is_empty() { + return Err(NntpError::Protocol { + code: 0, + message: "STARTTLS response left buffered plaintext".into(), + }); + } + let stream = self.reader.into_inner(); + let tls = timeout( + IO_TIMEOUT, + tls_connector().connect(server_name(host)?, stream.0), + ) + .await + .map_err(|_| NntpError::Timeout)? + .map_err(|e| NntpError::Tls(e.to_string()))?; + self.reader = BufReader::new(BoxedStream(Box::new(tls))); + Ok(self) + } + + async fn authenticate(&mut self, credentials: UsenetCredentials) -> Result<(), NntpError> { + let username = credentials + .username + .filter(|value| !value.is_empty()) + .ok_or_else(|| NntpError::AuthenticationFailed { + code: 481, + message: "username is required when credentials are configured".into(), + })?; + let user = self.command(&format!("AUTHINFO USER {username}")).await?; + if user.code == 281 { + return Ok(()); + } + if user.code != 381 { + return Err(authentication_error(user)); + } + let password = credentials + .password + .ok_or_else(|| NntpError::AuthenticationFailed { + code: 481, + message: "password is required by the server".into(), + })?; + let pass = self.command(&format!("AUTHINFO PASS {password}")).await?; + if pass.code != 281 { + return Err(authentication_error(pass)); + } + Ok(()) + } + + pub async fn capabilities(&mut self) -> Result { + let response = self.command("CAPABILITIES").await?; + if response.code != 101 { + return Err(response_error(response, "CAPABILITIES was not accepted")); + } + let lines = self.read_multiline().await?; + Ok(NntpCapabilities { + capabilities: lines + .into_iter() + .filter_map(|line| line.split_whitespace().next().map(str::to_ascii_uppercase)) + .collect(), + }) + } + + pub async fn get_capabilities(&mut self) -> Result { + self.capabilities().await + } + + pub async fn group(&mut self, group: &str) -> Result { + if group.trim().is_empty() + || group + .chars() + .any(|character| character.is_ascii_control() || character.is_whitespace()) + { + return Err(NntpError::Protocol { + code: 0, + message: "invalid newsgroup".into(), + }); + } + let response = self.command(&format!("GROUP {group}")).await?; + if response.code != 211 { + return Err(response_error(response, "GROUP failed")); + } + Ok(response) + } + + pub async fn select_group(&mut self, group: &str) -> Result { + self.group(group).await + } + + pub async fn article(&mut self, message_id: &str) -> Result, NntpError> { + let message_id = canonical_message_id(message_id)?; + let response = self.command(&format!("ARTICLE {message_id}")).await?; + if response.code != 220 && response.code != 221 && response.code != 222 { + return Err(article_error(response)); + } + let mut bytes = Vec::new(); + loop { + let line = self.read_line_bytes().await?; + if line == b"." { + break; + } + let line = if line.first() == Some(&b'.') { + &line[1..] + } else { + &line[..] + }; + if bytes.len().saturating_add(line.len()).saturating_add(1) > MAX_ARTICLE_BYTES { + return Err(NntpError::ArticleTooLarge); + } + bytes.extend_from_slice(line); + bytes.push(b'\n'); + } + Ok(bytes) + } + + pub async fn fetch_article(&mut self, message_id: &str) -> Result, NntpError> { + self.article(message_id).await + } + + async fn command(&mut self, command: &str) -> Result { + if command.contains(['\r', '\n']) { + return Err(NntpError::Protocol { + code: 0, + message: "invalid NNTP command".into(), + }); + } + timeout( + IO_TIMEOUT, + self.reader.get_mut().write_all(command.as_bytes()), + ) + .await + .map_err(|_| NntpError::Timeout)? + .map_err(NntpError::Io)?; + timeout(IO_TIMEOUT, self.reader.get_mut().write_all(b"\r\n")) + .await + .map_err(|_| NntpError::Timeout)? + .map_err(NntpError::Io)?; + timeout(IO_TIMEOUT, self.reader.get_mut().flush()) + .await + .map_err(|_| NntpError::Timeout)? + .map_err(NntpError::Io)?; + self.read_response().await + } + + async fn read_response(&mut self) -> Result { + let line = self.read_line().await?; + let code = line + .get(..3) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| NntpError::Protocol { + code: 0, + message: "malformed response code".into(), + })?; + Ok(NntpResponse { code }) + } + + async fn read_line(&mut self) -> Result { + let mut line = Vec::new(); + self.read_line_into(&mut line).await?; + while matches!(line.last(), Some(b'\r' | b'\n')) { + line.pop(); + } + String::from_utf8(line).map_err(|_| NntpError::Protocol { + code: 0, + message: "NNTP response is not UTF-8".into(), + }) + } + + async fn read_multiline(&mut self) -> Result, NntpError> { + let mut lines = Vec::new(); + let mut total_bytes = 0usize; + loop { + let line = self.read_line().await?; + if line == "." { + break; + } + if lines.len() >= MAX_MULTILINE_LINES { + return Err(NntpError::ArticleTooLarge); + } + total_bytes = total_bytes.saturating_add(line.len().saturating_add(1)); + if total_bytes > MAX_ARTICLE_BYTES { + return Err(NntpError::ArticleTooLarge); + } + lines.push(line); + } + Ok(lines) + } + + async fn read_line_bytes(&mut self) -> Result, NntpError> { + let mut line = Vec::new(); + self.read_line_into(&mut line).await?; + while matches!(line.last(), Some(b'\r' | b'\n')) { + line.pop(); + } + Ok(line) + } + + /// Read at most `MAX_LINE` bytes without allowing an unterminated line to + /// grow an unbounded temporary buffer. + async fn read_line_into(&mut self, line: &mut Vec) -> Result<(), NntpError> { + loop { + let available = timeout(IO_TIMEOUT, self.reader.fill_buf()) + .await + .map_err(|_| NntpError::Timeout)? + .map_err(NntpError::Io)?; + if available.is_empty() { + if line.is_empty() { + return Err(NntpError::Io(io::Error::new( + io::ErrorKind::UnexpectedEof, + "NNTP server closed the connection", + ))); + } + return Ok(()); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let take = newline.map_or(available.len(), |index| index + 1); + if line.len().saturating_add(take) > MAX_LINE { + return Err(NntpError::ResponseTooLong); + } + line.extend_from_slice(&available[..take]); + self.reader.consume(take); + if newline.is_some() { + return Ok(()); + } + } + } +} + +fn tls_connector() -> TlsConnector { + static CONNECTOR: OnceLock = OnceLock::new(); + CONNECTOR + .get_or_init(|| { + static INSTALL_PROVIDER: std::sync::Once = std::sync::Once::new(); + INSTALL_PROVIDER.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); + let mut roots = RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + TlsConnector::from(Arc::new( + ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + )) + }) + .clone() +} + +fn server_name(host: &str) -> Result, NntpError> { + if let Ok(ip) = host.parse::() { + return Ok(ServerName::from(ip)); + } + ServerName::try_from(host.to_owned()) + .map_err(|e| NntpError::Tls(format!("invalid server name: {e}"))) +} + +fn canonical_message_id(message_id: &str) -> Result { + if message_id.trim().is_empty() + || message_id + .chars() + .any(|character| character.is_ascii_control() || character.is_whitespace()) + { + return Err(invalid_article_id()); + } + + let has_open = message_id.starts_with('<'); + let has_close = message_id.ends_with('>'); + let inner = match (has_open, has_close) { + (false, false) => message_id, + (true, true) => &message_id[1..message_id.len() - 1], + _ => return Err(invalid_article_id()), + }; + if inner.is_empty() || inner.contains(['<', '>']) { + return Err(invalid_article_id()); + } + Ok(format!("<{inner}>")) +} + +fn invalid_article_id() -> NntpError { + NntpError::Protocol { + code: 0, + message: "invalid article id".into(), + } +} + +fn validate_profile(profile: &UsenetProviderProfile) -> Result<(), NntpError> { + if profile.id.trim().is_empty() + || profile.host.trim().is_empty() + || profile.port == 0 + || profile.max_connections == 0 + { + return Err(NntpError::InvalidProfile( + "id, host, a non-zero port, and a positive connection limit are required".into(), + )); + } + match profile.security_mode.as_str() { + "implicit-tls" | "starttls" => {} + "plain" if profile.allow_plain => {} + "plain" => return Err(NntpError::PlainNotAllowed), + _ => { + return Err(NntpError::InvalidProfile( + "unsupported security mode".into(), + )); + } + } + Ok(()) +} + +fn response_error(response: NntpResponse, context: &str) -> NntpError { + if matches!(response.code, 480..=483) { + NntpError::AuthenticationFailed { + code: response.code, + message: "server requires or rejected authentication".into(), + } + } else if matches!(response.code, 400 | 401 | 502) { + NntpError::ServiceUnavailable { + code: response.code, + message: context.into(), + } + } else { + NntpError::Protocol { + code: response.code, + message: context.into(), + } + } +} + +fn authentication_error(response: NntpResponse) -> NntpError { + NntpError::AuthenticationFailed { + code: response.code, + message: "server rejected credentials".into(), + } +} + +fn article_error(response: NntpResponse) -> NntpError { + if matches!(response.code, 430 | 423 | 412) { + NntpError::ArticleUnavailable { + code: response.code, + message: "article is not available from this provider".into(), + } + } else { + response_error(response, "ARTICLE failed") + } +} + +#[derive(Debug, Clone, Default)] +struct Health { + failures: u32, + unhealthy_until: Option, +} + +#[derive(Default)] +pub struct ProviderConnectionCapacityRegistry { + providers: Mutex>>, +} + +impl ProviderConnectionCapacityRegistry { + pub fn try_acquire( + &self, + profile: &UsenetProviderProfile, + ) -> Result, NntpError> { + validate_profile(profile)?; + let gate = self.gate_for(&profile.id); + Ok(gate.try_acquire(profile.max_connections as usize)) + } + + pub async fn acquire( + &self, + profile: &UsenetProviderProfile, + ) -> Result { + validate_profile(profile)?; + let gate = self.gate_for(&profile.id); + Ok(gate.acquire(profile.max_connections as usize).await) + } + + fn gate_for(&self, profile_id: &str) -> Arc { + self.providers + .lock() + .entry(profile_id.to_string()) + .or_insert_with(|| Arc::new(ProviderConnectionGate::new())) + .clone() + } +} + +struct ProviderConnectionGate { + state: Mutex, + changed: watch::Sender, +} + +#[derive(Default)] +struct ProviderConnectionGateState { + next_ticket: u64, + waiting: BTreeMap, + leased: BTreeMap, +} + +impl ProviderConnectionGate { + fn new() -> Self { + let (changed, _) = watch::channel(0); + Self { + state: Mutex::new(ProviderConnectionGateState::default()), + changed, + } + } + + async fn acquire(self: &Arc, limit: usize) -> ProviderConnectionLease { + let mut waiting = ProviderConnectionWaiter::new(self.clone(), limit); + let mut changed = self.changed.subscribe(); + loop { + if let Some(lease) = waiting.try_promote() { + return lease; + } + let _ = changed.changed().await; + } + } + + fn try_acquire(self: &Arc, limit: usize) -> Option { + let mut waiting = ProviderConnectionWaiter::new(self.clone(), limit); + waiting.try_promote() + } + + fn register_waiter(&self, limit: usize) -> u64 { + let ticket = { + let mut state = self.state.lock(); + let ticket = state.next_ticket; + state.next_ticket = state.next_ticket.wrapping_add(1); + state.waiting.insert(ticket, limit); + ticket + }; + self.signal(); + ticket + } + + fn try_promote(&self, ticket: u64) -> bool { + let promoted = { + let mut state = self.state.lock(); + if state.waiting.first_key_value().map(|(first, _)| *first) != Some(ticket) { + return false; + } + let limit = state + .waiting + .values() + .chain(state.leased.values()) + .copied() + .min() + .expect("waiting NNTP capacity ticket has no snapshot limit"); + if state.leased.len() >= limit { + return false; + } + let snapshot_limit = state + .waiting + .remove(&ticket) + .expect("waiting NNTP capacity ticket disappeared"); + state.leased.insert(ticket, snapshot_limit); + true + }; + if promoted { + self.signal(); + } + promoted + } + + fn cancel_waiter(&self, ticket: u64) { + let removed = self.state.lock().waiting.remove(&ticket).is_some(); + if removed { + self.signal(); + } + } + + fn release(&self, ticket: u64) { + let released = self.state.lock().leased.remove(&ticket).is_some(); + if released { + self.signal(); + } + } + + fn signal(&self) { + self.changed + .send_modify(|revision| *revision = revision.wrapping_add(1)); + } +} + +struct ProviderConnectionWaiter { + gate: Arc, + ticket: Option, +} + +impl ProviderConnectionWaiter { + fn new(gate: Arc, limit: usize) -> Self { + let ticket = gate.register_waiter(limit); + Self { + gate, + ticket: Some(ticket), + } + } + + fn try_promote(&mut self) -> Option { + let ticket = self.ticket?; + if !self.gate.try_promote(ticket) { + return None; + } + self.ticket = None; + Some(ProviderConnectionLease { + gate: self.gate.clone(), + ticket, + }) + } +} + +impl Drop for ProviderConnectionWaiter { + fn drop(&mut self) { + if let Some(ticket) = self.ticket { + self.gate.cancel_waiter(ticket); + } + } +} + +pub struct ProviderConnectionLease { + gate: Arc, + ticket: u64, +} + +impl Drop for ProviderConnectionLease { + fn drop(&mut self) { + self.gate.release(self.ticket); + } +} + +#[derive(Debug)] +pub struct ProviderPool { + profiles: Vec, + health: Mutex>, + cursor: Mutex, +} + +impl ProviderPool { + pub fn new(profiles: Vec) -> Result { + let profiles: Vec<_> = profiles + .into_iter() + .filter(|profile| profile.enabled && profile.deleted_at.is_none()) + .collect(); + for profile in &profiles { + crate::engine::usenet::validate_provider_profile(profile)?; + } + Ok(Self { + profiles, + health: Mutex::new(HashMap::new()), + cursor: Mutex::new(0), + }) + } + + pub fn profiles(&self) -> &[UsenetProviderProfile] { + &self.profiles + } + + pub fn ordered_profiles(&self) -> Vec { + let now = Instant::now(); + let health = self.health.lock(); + let mut groups: Vec<(i32, Vec)> = Vec::new(); + for profile in self + .profiles + .iter() + .filter(|profile| profile.enabled && profile.deleted_at.is_none()) + { + if let Some(state) = health.get(&profile.id) { + if state.unhealthy_until.is_some_and(|until| until > now) { + continue; + } + } + if let Some((_, group)) = groups + .iter_mut() + .find(|(priority, _)| *priority == profile.priority) + { + group.push(profile.clone()); + } else { + groups.push((profile.priority, vec![profile.clone()])); + } + } + groups.sort_by_key(|(priority, _)| *priority); + let mut ordered = Vec::new(); + let offset = *self.cursor.lock(); + for (_, mut group) in groups { + if !group.is_empty() { + let rotate = offset % group.len(); + group.rotate_left(rotate); + } + ordered.extend(group); + } + if ordered.is_empty() { + ordered.extend( + self.profiles + .iter() + .filter(|profile| profile.enabled && profile.deleted_at.is_none()) + .cloned(), + ); + ordered.sort_by_key(|profile| profile.priority); + } + ordered + } + + pub fn ordered_profiles_with_preference( + &self, + preferred_profile_id: Option<&str>, + ) -> Vec { + let mut ordered = self.ordered_profiles(); + let Some(preferred_profile_id) = preferred_profile_id else { + return ordered; + }; + let Some(preferred_index) = ordered + .iter() + .position(|profile| profile.id == preferred_profile_id) + else { + return ordered; + }; + if preferred_index > 0 && ordered[preferred_index].priority == ordered[0].priority { + ordered.swap(0, preferred_index); + } + ordered + } + + pub fn mark_success(&self, profile_id: &str) { + self.health.lock().remove(profile_id); + let mut cursor = self.cursor.lock(); + *cursor = cursor.wrapping_add(1); + } + + pub fn mark_failure(&self, profile_id: &str, error: &NntpError) { + if matches!(error, NntpError::ArticleUnavailable { .. }) { + return; + } + let mut health = self.health.lock(); + let state = health.entry(profile_id.to_string()).or_default(); + state.failures = state.failures.saturating_add(1); + if error.failover_class() != FailoverClass::Permanent { + state.unhealthy_until = Some(Instant::now() + HEALTH_COOLDOWN); + } + } + + pub async fn run_with_failover(&self, operation: F) -> Result + where + F: Fn(UsenetProviderProfile) -> Fut, + Fut: std::future::Future>, + { + self.run_with_failover_preferring(None, operation) + .await + .map(|(_, value)| value) + } + + pub async fn run_with_failover_preferring( + &self, + preferred_profile_id: Option<&str>, + operation: F, + ) -> Result<(String, T), NntpError> + where + F: Fn(UsenetProviderProfile) -> Fut, + Fut: std::future::Future>, + { + let candidates = self.ordered_profiles_with_preference(preferred_profile_id); + if candidates.is_empty() { + return Err(NntpError::InvalidProfile( + "no enabled Usenet providers".into(), + )); + } + let mut last_error = None; + for profile in candidates { + let result = operation(profile.clone()).await; + match result { + Ok(value) => { + self.mark_success(&profile.id); + return Ok((profile.id, value)); + } + Err(error) => { + let class = error.failover_class(); + self.mark_failure(&profile.id, &error); + last_error = Some(error); + if class == FailoverClass::Permanent { + break; + } + } + } + } + Err(last_error + .unwrap_or_else(|| NntpError::InvalidProfile("no provider candidates".into()))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + fn profile(id: &str, priority: i32) -> UsenetProviderProfile { + UsenetProviderProfile { + id: id.into(), + name: id.into(), + host: "localhost".into(), + port: 119, + security_mode: "plain".into(), + enabled: true, + priority, + max_connections: 2, + allow_plain: true, + deleted_at: None, + updated_at: None, + } + } + + #[test] + fn equal_priority_profiles_rotate_and_lower_priority_wins() { + let pool = + ProviderPool::new(vec![profile("a", 0), profile("b", 0), profile("c", 1)]).unwrap(); + assert_eq!(pool.ordered_profiles()[0].id, "a"); + pool.mark_success("a"); + assert_eq!(pool.ordered_profiles()[0].id, "b"); + } + + #[test] + fn inactive_profiles_are_ignored_before_validation() { + let mut disabled = profile("disabled", 0); + disabled.enabled = false; + disabled.host.clear(); + disabled.max_connections = 0; + + let mut tombstone = profile("tombstone", 0); + tombstone.deleted_at = Some(1); + tombstone.host.clear(); + tombstone.max_connections = 0; + + let pool = ProviderPool::new(vec![disabled, tombstone, profile("active", 0)]).unwrap(); + assert_eq!( + pool.profiles() + .iter() + .map(|profile| profile.id.as_str()) + .collect::>(), + ["active"] + ); + } + + #[test] + fn enabled_invalid_profiles_are_still_rejected() { + let mut invalid = profile("invalid", 0); + invalid.host.clear(); + assert!(ProviderPool::new(vec![invalid]).is_err()); + } + + #[test] + fn unhealthy_profiles_are_skipped() { + let pool = ProviderPool::new(vec![profile("a", 0), profile("b", 0)]).unwrap(); + pool.mark_failure( + "a", + &NntpError::Io(io::Error::new(io::ErrorKind::ConnectionRefused, "down")), + ); + assert_eq!(pool.ordered_profiles()[0].id, "b"); + } + + #[test] + fn unavailable_article_does_not_cool_down_a_healthy_provider() { + let pool = ProviderPool::new(vec![profile("a", 0), profile("b", 1)]).unwrap(); + + pool.mark_failure( + "a", + &NntpError::ArticleUnavailable { + code: 430, + message: "expired".into(), + }, + ); + + assert_eq!(pool.ordered_profiles()[0].id, "a"); + } + + #[tokio::test] + async fn capacity_registry_honors_overlapping_profile_snapshots() { + let registry = Arc::new(ProviderConnectionCapacityRegistry::default()); + let high_limit = profile("shared", 0); + let mut low_limit = high_limit.clone(); + low_limit.max_connections = 1; + + let first = registry.acquire(&high_limit).await.unwrap(); + let registry_for_waiter = registry.clone(); + let mut lower_waiter = + tokio::spawn(async move { registry_for_waiter.acquire(&low_limit).await.unwrap() }); + assert!( + timeout(Duration::from_millis(100), &mut lower_waiter) + .await + .is_err(), + "a lower live profile snapshot did not constrain new streams" + ); + + drop(first); + let lower_lease = timeout(Duration::from_secs(3), lower_waiter) + .await + .expect("lower snapshot did not wake after the old stream closed") + .unwrap(); + drop(lower_lease); + + let high_one = registry.acquire(&high_limit).await.unwrap(); + let high_two = timeout(Duration::from_secs(3), registry.acquire(&high_limit)) + .await + .expect("capacity did not return to the remaining profile snapshot") + .unwrap(); + drop(high_one); + drop(high_two); + } + + #[test] + fn plain_requires_opt_in_and_classes_are_stable() { + let mut p = profile("a", 0); + p.allow_plain = false; + assert!(matches!( + validate_profile(&p), + Err(NntpError::PlainNotAllowed) + )); + assert_eq!( + NntpError::ArticleUnavailable { + code: 430, + message: "x".into() + } + .failover_class(), + FailoverClass::Retryable + ); + assert_eq!( + NntpError::AuthenticationFailed { + code: 481, + message: "x".into() + } + .failover_class(), + FailoverClass::Authentication + ); + } + + #[test] + fn canonical_message_id_wraps_bare_values_once() { + assert_eq!(canonical_message_id("id@nyuu").unwrap(), ""); + assert_eq!(canonical_message_id("").unwrap(), ""); + assert!(canonical_message_id("").is_err()); + assert!(canonical_message_id("id @nyuu").is_err()); + assert!(canonical_message_id("id@nyuu\r\nGROUP alt.test").is_err()); + } + + #[tokio::test] + async fn plain_server_supports_auth_capabilities_group_and_article() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + reader + .get_mut() + .write_all(b"200 test server ready\r\n") + .await + .unwrap(); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).await.unwrap() == 0 { + break; + } + let command = line.trim_end_matches(['\r', '\n']); + let response = if command == "AUTHINFO USER alice" { + "381 password required\r\n" + } else if command == "AUTHINFO PASS secret" { + "281 authenticated\r\n" + } else if command == "CAPABILITIES" { + "101 capabilities\r\nSTARTTLS\r\nAUTHINFO USER\r\n.\r\n" + } else if command == "GROUP alt.test" { + "211 1 1 1 alt.test\r\n" + } else if command == "ARTICLE " { + "220 article follows\r\nhello\r\n..dot\r\n.\r\n" + } else { + "500 unknown command\r\n" + }; + reader + .get_mut() + .write_all(response.as_bytes()) + .await + .unwrap(); + if command == "ARTICLE " { + break; + } + } + }); + + let mut p = profile("plain", 0); + p.port = port; + let mut connection = NntpConnection::connect( + &p, + Some(UsenetCredentials { + username: Some("alice".into()), + password: Some("secret".into()), + }), + ) + .await + .unwrap(); + let capabilities = connection.capabilities().await.unwrap(); + assert!(capabilities.supports("starttls")); + connection.group("alt.test").await.unwrap(); + assert_eq!( + connection.article("id@example").await.unwrap(), + b"hello\n.dot\n" + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn rejects_an_unterminated_response_line_without_buffering_past_the_cap() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + reader + .get_mut() + .write_all(b"200 test server ready\r\n") + .await + .unwrap(); + let mut command = String::new(); + reader.read_line(&mut command).await.unwrap(); + reader + .get_mut() + .write_all(b"220 article follows\r\n") + .await + .unwrap(); + reader + .get_mut() + .write_all(&vec![b'x'; MAX_LINE + 1]) + .await + .unwrap(); + }); + + let mut p = profile("bounded", 0); + p.port = port; + let mut connection = NntpConnection::connect(&p, None).await.unwrap(); + assert!(matches!( + connection.article("id@example").await, + Err(NntpError::ResponseTooLong) + )); + server.await.unwrap(); + } + + #[tokio::test] + async fn bounds_capabilities_empty_line_count() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + reader + .get_mut() + .write_all(b"200 test server ready\r\n") + .await + .unwrap(); + let mut command = String::new(); + reader.read_line(&mut command).await.unwrap(); + let mut response = b"101 capabilities\r\n".to_vec(); + response.extend(std::iter::repeat_n(b'\n', MAX_MULTILINE_LINES + 1)); + response.extend_from_slice(b".\r\n"); + reader.get_mut().write_all(&response).await.unwrap(); + }); + + let mut p = profile("bounded-lines", 0); + p.port = port; + let mut connection = NntpConnection::connect(&p, None).await.unwrap(); + assert!(matches!( + connection.capabilities().await, + Err(NntpError::ArticleTooLarge) + )); + server.await.unwrap(); + } + + #[tokio::test] + async fn rejects_starttls_when_plaintext_is_buffered() { + let (mut peer, stream) = tokio::io::duplex(64); + peer.write_all(b"pending plaintext").await.unwrap(); + let mut connection = NntpConnection { + reader: BufReader::new(BoxedStream(Box::new(stream))), + profile_id: "buffered".into(), + greeted: true, + }; + connection.reader.fill_buf().await.unwrap(); + + let error = connection.upgrade_tls("localhost").await.unwrap_err(); + + assert!(matches!(error, NntpError::Protocol { code: 0, .. })); + } + + #[tokio::test] + async fn rejects_zero_connection_capacity_at_the_registry_boundary() { + let registry = ProviderConnectionCapacityRegistry::default(); + let mut p = profile("zero", 0); + p.max_connections = 0; + assert!(matches!( + registry.acquire(&p).await, + Err(NntpError::InvalidProfile(_)) + )); + } + + #[tokio::test] + async fn failover_retries_transient_provider_errors() { + let pool = ProviderPool::new(vec![profile("a", 0), profile("b", 1)]).unwrap(); + let attempts = Arc::new(Mutex::new(Vec::new())); + let seen = attempts.clone(); + let value = pool + .run_with_failover(|provider| { + let seen = seen.clone(); + async move { + seen.lock().push(provider.id.clone()); + if provider.id == "a" { + Err(NntpError::ServiceUnavailable { + code: 400, + message: "offline".into(), + }) + } else { + Ok("article") + } + } + }) + .await + .unwrap(); + assert_eq!(value, "article"); + assert_eq!(&*attempts.lock(), &["a", "b"]); + } +} diff --git a/src-tauri/risuko-engine/src/engine/usenet_worker.rs b/src-tauri/risuko-engine/src/engine/usenet_worker.rs new file mode 100644 index 00000000..5bbbf4ed --- /dev/null +++ b/src-tauri/risuko-engine/src/engine/usenet_worker.rs @@ -0,0 +1,2302 @@ +//! Task worker for the native Usenet pipeline + +use crate::engine::archive_pipeline::{cleanup_after_success, is_archive_volume_name, CleanupMode}; +use crate::engine::task::{DownloadTask, UsenetRepairFailure, UsenetTaskData}; +use crate::engine::usenet::{ + NzbSegment, UsenetCredentialResolver, UsenetCredentials, UsenetProviderProfile, +}; +use crate::engine::usenet_par2::{ + platform_limits, verify_or_repair_with_cancel, Par2Error, Par2InputFile, Par2RepairRequest, +}; +use crate::engine::usenet_pipeline::{ + assemble_file_with_report_with_limits_at_offset, decode_yenc_part, mark_par2_repaired, + partial_path, resume_sidecar_matches, resume_sidecar_path, ArticleFetchError, ArticleSource, + AssemblyReport, DecodedYencPart, ResumeSidecar, YencAssemblyBudget, YencAssemblyLimits, +}; +use crate::engine::usenet_transport::{ + NntpConnection, NntpError, ProviderConnectionCapacityRegistry, ProviderConnectionLease, + ProviderPool, +}; +use fs4::{FileExt, TryLockError}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeSet, HashMap}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex as AsyncMutex; +use tokio_util::sync::CancellationToken; + +#[derive(Default)] +pub struct AnonymousCredentialResolver; + +#[async_trait::async_trait] +impl UsenetCredentialResolver for AnonymousCredentialResolver { + async fn resolve(&self, _profile_id: &str) -> Result, String> { + Ok(None) + } +} + +/// A transient worker failure plus the non-secret task outcome it can carry +#[derive(Debug)] +pub(crate) struct UsenetDownloadError { + message: String, + repair_failure: Option, +} + +impl UsenetDownloadError { + fn from_par2_error(error: Par2Error, unavailable: &[String]) -> Self { + let repair_failure = match &error { + Par2Error::InsufficientRecovery { needed, available } => Some(UsenetRepairFailure { + needed_blocks: *needed, + available_blocks: *available, + partials_retained: true, + }), + _ => None, + }; + Self { + message: format!("{error}; unavailable segments: {}", unavailable.join(", ")), + repair_failure, + } + } + + pub(crate) fn repair_failure(&self) -> Option<&UsenetRepairFailure> { + self.repair_failure.as_ref() + } +} + +impl From for UsenetDownloadError { + fn from(message: String) -> Self { + Self { + message, + repair_failure: None, + } + } +} + +impl fmt::Display for UsenetDownloadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for UsenetDownloadError {} + +struct NntpArticleSource { + pool: Arc, + resolver: Arc, + connection_capacity: Arc, + profile_sessions: ProfileSessionCache, + preferred_profile_id: Mutex>, + active_time: ActiveTimeTracker, + max_active_seconds: u64, + cancel: CancellationToken, +} + +/// Non-serializable state +#[derive(Default)] +struct ProfileSessionCache { + sessions: Mutex>>>, +} + +#[derive(Default)] +struct ProfileSession { + credentials: Option>, + connection: Option, +} + +struct LeasedNntpConnection { + connection: NntpConnection, + _capacity_lease: ProviderConnectionLease, +} + +impl ProfileSessionCache { + fn session_for(&self, profile_id: &str) -> Arc> { + self.sessions + .lock() + .unwrap_or_else(|error| error.into_inner()) + .entry(profile_id.to_string()) + .or_insert_with(|| Arc::new(AsyncMutex::new(ProfileSession::default()))) + .clone() + } + + async fn discard_connections_except(&self, profile_id: &str) { + let other_sessions = self + .sessions + .lock() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .filter(|(id, _)| id.as_str() != profile_id) + .map(|(_, session)| session.clone()) + .collect::>(); + for session in other_sessions { + session.lock().await.connection.take(); + } + } +} + +#[derive(Clone, Copy)] +enum ConnectionAdmission { + Immediate, + Wait, +} + +#[derive(Clone)] +struct ActiveTimeTracker { + started_at: Instant, + credential_wait: Arc>, +} + +impl ActiveTimeTracker { + fn new() -> Self { + Self { + started_at: Instant::now(), + credential_wait: Arc::new(Mutex::new(Duration::ZERO)), + } + } + + fn active_elapsed(&self) -> Duration { + let credential_wait = *self + .credential_wait + .lock() + .unwrap_or_else(|error| error.into_inner()); + self.started_at.elapsed().saturating_sub(credential_wait) + } + + fn record_credential_wait(&self, elapsed: Duration) { + let mut credential_wait = self + .credential_wait + .lock() + .unwrap_or_else(|error| error.into_inner()); + *credential_wait = credential_wait + .checked_add(elapsed) + .unwrap_or(Duration::MAX); + } +} + +impl ArticleSource for NntpArticleSource { + fn fetch<'a>( + &'a self, + message_id: &'a str, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + Box::pin(async move { + ensure_usenet_active_time(&self.active_time, self.max_active_seconds) + .map_err(ArticleFetchError::Failed)?; + let message_id = message_id.to_string(); + let preferred_profile_id = self + .preferred_profile_id + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + let result = self + .fetch_with_failover(&message_id, preferred_profile_id.as_deref()) + .await; + if let Ok((profile_id, _)) = &result { + *self + .preferred_profile_id + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some(profile_id.clone()); + } + ensure_usenet_active_time(&self.active_time, self.max_active_seconds) + .map_err(ArticleFetchError::Failed)?; + result + .map(|(_, article)| article) + .map_err(article_fetch_error) + }) + } +} + +impl NntpArticleSource { + async fn fetch_with_failover( + &self, + message_id: &str, + preferred_profile_id: Option<&str>, + ) -> Result<(String, DecodedYencPart), NntpError> { + let candidates = self + .pool + .ordered_profiles_with_preference(preferred_profile_id); + if candidates.is_empty() { + return Err(NntpError::InvalidProfile( + "no enabled Usenet providers".into(), + )); + } + + let mut capacity_waiters = Vec::new(); + let mut last_error = None; + for profile in candidates { + match self + .fetch_from_profile(profile.clone(), message_id, ConnectionAdmission::Immediate) + .await + { + Ok(part) => { + self.pool.mark_success(&profile.id); + return Ok((profile.id, part)); + } + Err(NntpError::CapacityUnavailable) => capacity_waiters.push(profile), + Err(error) => { + let class = error.failover_class(); + self.pool.mark_failure(&profile.id, &error); + if class == super::usenet_transport::FailoverClass::Permanent { + return Err(error); + } + last_error = Some(error); + } + } + } + + for profile in capacity_waiters { + match self + .fetch_from_profile(profile.clone(), message_id, ConnectionAdmission::Wait) + .await + { + Ok(part) => { + self.pool.mark_success(&profile.id); + return Ok((profile.id, part)); + } + Err(error) => { + let class = error.failover_class(); + self.pool.mark_failure(&profile.id, &error); + if class == super::usenet_transport::FailoverClass::Permanent { + return Err(error); + } + last_error = Some(error); + } + } + } + + Err(last_error.unwrap_or(NntpError::CapacityUnavailable)) + } + + async fn fetch_from_profile( + &self, + profile: UsenetProviderProfile, + message_id: &str, + admission: ConnectionAdmission, + ) -> Result { + let article = self + .fetch_article_from_profile(profile, message_id, admission) + .await?; + decode_yenc_part(&article).map_err(|message| NntpError::ArticleCorrupt { message }) + } + + async fn fetch_article_from_profile( + &self, + profile: UsenetProviderProfile, + message_id: &str, + admission: ConnectionAdmission, + ) -> Result, NntpError> { + let profile_session = self.profile_sessions.session_for(&profile.id); + let mut session = profile_session.lock().await; + let mut retried_stale_connection = false; + + loop { + let used_cached_connection = session.connection.is_some(); + if !used_cached_connection { + let credentials = self.cached_credentials(&mut session, &profile.id).await?; + let capacity_lease = match admission { + ConnectionAdmission::Immediate => { + self.acquire_connection_capacity(&profile, admission) + .await? + } + ConnectionAdmission::Wait => { + self.profile_sessions + .discard_connections_except(&profile.id) + .await; + self.acquire_connection_capacity(&profile, admission) + .await? + } + }; + if matches!(admission, ConnectionAdmission::Immediate) { + self.profile_sessions + .discard_connections_except(&profile.id) + .await; + } + let connection = self.connect_with_cancel(&profile, credentials).await?; + session.connection = Some(LeasedNntpConnection { + connection, + _capacity_lease: capacity_lease, + }); + } + + let article = { + let connection = + session + .connection + .as_mut() + .ok_or_else(|| NntpError::Protocol { + code: 0, + message: "NNTP session was not initialized".into(), + })?; + tokio::select! { + biased; + _ = self.cancel.cancelled() => Err(NntpError::Cancelled), + article = connection.connection.article(message_id) => article, + } + }; + match article { + Ok(article) => return Ok(article), + Err(error) => { + let retry_stale_connection = used_cached_connection + && !retried_stale_connection + && should_retry_stale_connection(&error); + if should_discard_connection(&error) { + session.connection.take(); + } + if retry_stale_connection { + retried_stale_connection = true; + continue; + } + return Err(error); + } + } + } + } + + async fn cached_credentials( + &self, + session: &mut ProfileSession, + profile_id: &str, + ) -> Result, NntpError> { + if let Some(credentials) = &session.credentials { + return Ok(credentials.clone()); + } + + let credential_wait_started_at = Instant::now(); + let credentials = tokio::select! { + biased; + _ = self.cancel.cancelled() => { + self.active_time + .record_credential_wait(credential_wait_started_at.elapsed()); + return Err(NntpError::Cancelled); + } + credentials = self.resolver.resolve(profile_id) => credentials, + }; + self.active_time + .record_credential_wait(credential_wait_started_at.elapsed()); + let credentials = credentials.map_err(|error| NntpError::Protocol { + code: 0, + message: error, + })?; + session.credentials = Some(credentials.clone()); + Ok(credentials) + } + + async fn acquire_connection_capacity( + &self, + profile: &UsenetProviderProfile, + admission: ConnectionAdmission, + ) -> Result { + if self.cancel.is_cancelled() { + return Err(NntpError::Cancelled); + } + match admission { + ConnectionAdmission::Immediate => self + .connection_capacity + .try_acquire(profile)? + .ok_or(NntpError::CapacityUnavailable), + ConnectionAdmission::Wait => { + let remaining = self.remaining_active_time()?; + tokio::select! { + biased; + _ = self.cancel.cancelled() => Err(NntpError::Cancelled), + lease = tokio::time::timeout( + remaining, + self.connection_capacity.acquire(profile), + ) => match lease { + Ok(result) => result, + Err(_) => Err(active_time_limit_error()), + }, + } + } + } + } + + fn remaining_active_time(&self) -> Result { + let limit = Duration::from_secs(self.max_active_seconds); + limit + .checked_sub(self.active_time.active_elapsed()) + .ok_or_else(active_time_limit_error) + } + + async fn connect_with_cancel( + &self, + profile: &UsenetProviderProfile, + credentials: Option, + ) -> Result { + tokio::select! { + biased; + _ = self.cancel.cancelled() => Err(NntpError::Cancelled), + connection = NntpConnection::connect(profile, credentials) => connection, + } + } +} + +fn should_discard_connection(error: &NntpError) -> bool { + !matches!( + error, + NntpError::ArticleUnavailable { .. } | NntpError::ArticleCorrupt { .. } + ) +} + +fn should_retry_stale_connection(error: &NntpError) -> bool { + matches!(error, NntpError::Io(_) | NntpError::Timeout) +} + +fn ensure_usenet_active_time( + active_time: &ActiveTimeTracker, + max_active_seconds: u64, +) -> Result<(), String> { + if active_time.active_elapsed().as_secs() > max_active_seconds { + Err("archive limit: Usenet task exceeded the active-time limit".into()) + } else { + Ok(()) + } +} + +fn active_time_limit_error() -> NntpError { + NntpError::Protocol { + code: 0, + message: "archive limit: Usenet task exceeded the active-time limit".into(), + } +} + +fn article_fetch_error(error: NntpError) -> ArticleFetchError { + match error { + NntpError::ArticleUnavailable { .. } | NntpError::ArticleCorrupt { .. } => { + ArticleFetchError::Unavailable(error.to_string()) + } + _ => ArticleFetchError::Failed(error.to_string()), + } +} + +struct AssembledTaskFile { + index: usize, + name: String, + is_parity: bool, + report: AssemblyReport, + _reservation: OutputReservation, +} + +struct OutputReservation { + output: PathBuf, + _lock_path: PathBuf, + lock_file: Option, +} + +impl Drop for OutputReservation { + fn drop(&mut self) { + drop(self.lock_file.take()); + } +} + +type StageCallback = Arc; + +fn report_stage(stage: &Option, value: &str) { + if let Some(callback) = stage { + callback(value); + } +} + +pub fn profiles_from_options( + options: &Map, +) -> Result, String> { + let value = options + .get("usenet-profiles") + .or_else(|| options.get("usenetProfiles")) + .ok_or_else(|| "No Usenet provider profiles configured".to_string())?; + serde_json::from_value(value.clone()) + .map_err(|error| format!("Invalid Usenet provider profiles: {error}")) +} + +pub async fn run_usenet_download_with_resolver( + task: &DownloadTask, + options: &Map, + completed: Arc, + total: Arc, + cancel: CancellationToken, + resolver: Arc, +) -> Result { + run_usenet_download_with_resolver_and_capacity( + task, + options, + completed, + total, + cancel, + resolver, + Arc::new(ProviderConnectionCapacityRegistry::default()), + None, + ) + .await + .map(|(path, _)| path) + .map_err(|error| error.to_string()) +} + +pub(crate) async fn run_usenet_download_with_resolver_and_capacity( + task: &DownloadTask, + options: &Map, + completed: Arc, + total: Arc, + cancel: CancellationToken, + resolver: Arc, + connection_capacity: Arc, + stage: Option, +) -> Result<(PathBuf, Vec<(usize, PathBuf)>), UsenetDownloadError> { + let metadata: &UsenetTaskData = task + .usenet + .as_ref() + .ok_or_else(|| "Usenet task has no manifest metadata".to_string())?; + let mut profiles = profiles_from_options(options)?; + if let Some(selected) = metadata.options.profile_id.as_deref() { + profiles.retain(|profile| profile.id == selected); + if profiles.is_empty() { + return Err(format!("Usenet provider profile {selected:?} is unavailable").into()); + } + } + let pool = Arc::new(ProviderPool::new(profiles).map_err(|error| error.to_string())?); + let archive_limits = archive_limits_for_task(metadata, options)?; + let active_time = ActiveTimeTracker::new(); + let source = NntpArticleSource { + pool, + resolver, + connection_capacity, + profile_sessions: ProfileSessionCache::default(), + preferred_profile_id: Mutex::new(None), + active_time: active_time.clone(), + max_active_seconds: archive_limits.max_active_seconds, + cancel: cancel.clone(), + }; + let assembly_limits = YencAssemblyLimits::new( + archive_limits.max_entry_bytes, + archive_limits.max_expanded_bytes, + )?; + let mut assembly_budget = YencAssemblyBudget::default(); + let destination = PathBuf::from(&task.dir); + tokio::fs::create_dir_all(&destination) + .await + .map_err(|error| format!("create Usenet destination: {error}"))?; + let expected_total = task_article_bytes(metadata)?; + total.store(expected_total, Ordering::Relaxed); + + let mut assembled = Vec::new(); + let mut promoted_outputs = Vec::new(); + report_stage(&stage, "fetching"); + for (index, file) in metadata.files.iter().enumerate() { + ensure_usenet_active_time(&active_time, archive_limits.max_active_seconds)?; + if cancel.is_cancelled() { + return Err("Download cancelled".to_string().into()); + } + let name = crate::engine::util::safe_filename(&file.name, "download"); + let segments: Vec = file + .segments + .iter() + .map(|segment| NzbSegment { + number: segment.number, + bytes: segment.bytes, + message_id: segment.message_id.clone(), + }) + .collect(); + let reservation = reserve_output_path(&destination, &name, &segments).await?; + let output = reservation.output.clone(); + let local_before = completed.load(Ordering::Relaxed); + report_stage(&stage, "assembling"); + let report = assemble_file_with_report_with_limits_at_offset( + &output, + &segments, + &source, + &cancel, + Some(completed.as_ref()), + local_before, + assembly_limits, + &mut assembly_budget, + ) + .await?; + ensure_usenet_active_time(&active_time, archive_limits.max_active_seconds)?; + assembled.push(AssembledTaskFile { + index, + is_parity: is_par2_name(&name), + name, + report, + _reservation: reservation, + }); + } + drop(source); + + let needs_repair = assembled + .iter() + .any(|file| !file.is_parity && !file.report.complete); + let parity_files: Vec = assembled + .iter() + .filter(|file| file.is_parity && file.report.complete) + .map(|file| file.report.output.clone()) + .collect(); + let data_files: Vec = assembled + .iter() + .filter(|file| !file.is_parity) + .map(|file| Par2InputFile { + manifest_name: file.name.clone(), + source_path: file.report.source_path().to_path_buf(), + output_path: file.report.output.clone(), + expected_size: file.report.expected_size, + }) + .collect(); + let unavailable = assembled + .iter() + .filter(|file| !file.report.unavailable_segments.is_empty()) + .map(|file| format!("{}:{:?}", file.name, file.report.unavailable_segments)) + .collect::>(); + if needs_repair && parity_files.is_empty() { + return Err(format!( + "PAR2 repair failed: no complete parity files are available; unavailable segments: {}", + unavailable.join(", ") + ) + .into()); + } + if !data_files.is_empty() && !parity_files.is_empty() { + report_stage( + &stage, + if needs_repair { + "repairing" + } else { + "verifying" + }, + ); + let request = Par2RepairRequest { + destination: destination.clone(), + data_files, + parity_files, + required_incomplete_names: assembled + .iter() + .filter(|file| !file.is_parity && !file.report.complete) + .map(|file| file.name.clone()) + .collect::>(), + limits: archive_limits, + active_started_at: Some(Instant::now()), + active_elapsed_before_repair: Some(active_time.active_elapsed()), + }; + let repair_cancel = cancel.child_token(); + let repair_cancel_for_worker = repair_cancel.clone(); + let mut repair_task = tokio::task::spawn_blocking(move || { + verify_or_repair_with_cancel(&request, Some(&repair_cancel_for_worker)) + }); + let remaining = Duration::from_secs(archive_limits.max_active_seconds) + .checked_sub(active_time.active_elapsed()) + .ok_or_else(|| { + "archive limit: Usenet task exceeded the active-time limit".to_string() + })?; + let repair = tokio::select! { + _ = cancel.cancelled() => { + repair_cancel.cancel(); + let _ = (&mut repair_task).await; + return Err(String::from("Download cancelled").into()); + } + result = tokio::time::timeout(remaining, &mut repair_task) => { + match result { + Ok(joined) => joined.map_err(|error| format!("PAR2 repair worker failed: {error}"))?, + Err(_) => { + repair_cancel.cancel(); + let _ = (&mut repair_task).await; + return Err(String::from( + "archive limit: Usenet task exceeded the active-time limit", + ) + .into()); + } + } + } + } + .map_err(|error| UsenetDownloadError::from_par2_error(error, &unavailable))?; + persist_par2_repair_state(&assembled, &repair.promoted_outputs).await?; + promoted_outputs = repair.promoted_outputs.clone(); + let unrepaired = assembled + .iter() + .filter(|file| { + !file.is_parity + && !file.report.complete + && !repair.promoted_outputs.contains(&file.report.output) + }) + .map(|file| file.name.as_str()) + .collect::>(); + if !unrepaired.is_empty() { + return Err(format!( + "PAR2 repair failed: no recovery set covers incomplete files {}; unavailable segments: {}", + unrepaired.join(", "), + unavailable.join(", ") + ) + .into()); + } + } + ensure_usenet_active_time(&active_time, archive_limits.max_active_seconds)?; + completed.store(expected_total, Ordering::Relaxed); + let cleanup_mode = cleanup_mode_for_task(metadata, options); + // This worker assembles and verifies NZB data but does not extract archives, + // so archive-volume cleanup is intentionally disabled in cleanup_inputs. + let (par2_inputs, archive_inputs) = cleanup_inputs(&assembled, false, &promoted_outputs); + if let Err(error) = cleanup_after_success(cleanup_mode, true, &par2_inputs, &archive_inputs) { + tracing::warn!( + mode = ?cleanup_mode, + %error, + "Usenet cleanup after verified success was incomplete" + ); + } + report_stage(&stage, "complete"); + Ok(( + assembled + .first() + .map(|file| file.report.output.clone()) + .unwrap_or_else(|| destination.clone()), + assembled + .iter() + .map(|file| (file.index, file.report.output.clone())) + .collect(), + )) +} + +fn cleanup_mode_for_task(metadata: &UsenetTaskData, options: &Map) -> CleanupMode { + let configured = metadata + .options + .cleanup_mode + .as_deref() + .or_else(|| options.get("usenet-cleanup-mode").and_then(Value::as_str)); + let mode = CleanupMode::from_setting(configured); + if mode == CleanupMode::DeletePar2AndVolumes { + tracing::warn!( + "Usenet archive-volume cleanup is unsupported because this worker does not verify extraction" + ); + CleanupMode::DeletePar2 + } else { + mode + } +} + +fn cleanup_inputs( + assembled: &[AssembledTaskFile], + archive_extraction_verified: bool, + promoted_outputs: &[PathBuf], +) -> (Vec, Vec) { + let mut par2_inputs = Vec::new(); + let mut archive_inputs = Vec::new(); + for file in assembled { + let verified_complete = file.report.complete + || promoted_outputs + .iter() + .any(|output| output == &file.report.output); + if !verified_complete { + continue; + } + let target = if file.is_parity { + &mut par2_inputs + } else if archive_extraction_verified && is_archive_volume_name(&file.name) { + &mut archive_inputs + } else { + continue; + }; + for path in [&file.report.output, &file.report.part_path] { + if !target.iter().any(|existing| existing == path) { + target.push(path.clone()); + } + } + } + (par2_inputs, archive_inputs) +} + +fn is_par2_name(name: &str) -> bool { + Path::new(name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("par2")) +} + +fn task_article_bytes(metadata: &UsenetTaskData) -> Result { + metadata + .files + .iter() + .flat_map(|file| file.segments.iter()) + .try_fold(0u64, |total, segment| { + total + .checked_add(segment.bytes) + .ok_or_else(|| "NZB article byte count overflowed".to_string()) + }) +} + +fn archive_limits_for_task( + metadata: &UsenetTaskData, + options: &Map, +) -> Result { + let defaults = platform_limits(); + let value = metadata + .options + .archive_limits + .as_ref() + .or_else(|| options.get("usenet-archive-limits")); + let Some(value) = value else { + return Ok(defaults); + }; + crate::engine::archive_safety::validate_limits_override_value( + defaults, + value, + metadata.options.archive_limit_override_confirmed + || options + .get("usenet-archive-limit-override-confirmed") + .and_then(Value::as_bool) + .unwrap_or(false), + ) + .map_err(|error| format!("Invalid Usenet archive limits: {error:?}")) +} + +async fn reserve_output_path( + destination: &Path, + name: &str, + segments: &[NzbSegment], +) -> Result { + let path = Path::new(name); + let stem = path + .file_stem() + .and_then(|v| v.to_str()) + .unwrap_or("download"); + let extension = path.extension().and_then(|v| v.to_str()); + for index in 0..10_000u32 { + let candidate = if index == 0 { + destination.join(name) + } else { + let renamed = match extension { + Some(extension) => format!("{stem} ({index}).{extension}"), + None => format!("{stem} ({index})"), + }; + destination.join(renamed) + }; + let is_resume = resume_sidecar_matches(&candidate, segments).await?; + if is_resume || output_slot_is_available(&candidate).await? { + if let Some(reservation) = try_reserve_output(&candidate).await? { + if resume_sidecar_matches(&candidate, segments).await? + || output_slot_is_available(&candidate).await? + { + return Ok(reservation); + } + } + } + } + for _ in 0..100 { + let candidate = destination.join(format!("{name}.{}", uuid::Uuid::new_v4().simple())); + if output_slot_is_available(&candidate).await? { + if let Some(reservation) = try_reserve_output(&candidate).await? { + if output_slot_is_available(&candidate).await? { + return Ok(reservation); + } + } + } + } + Err(format!( + "could not allocate a Usenet output path for {name:?}" + )) +} + +async fn try_reserve_output(output: &Path) -> Result, String> { + let output = output.to_path_buf(); + let lock_path = output_lock_path(&output)?; + tokio::task::spawn_blocking(move || { + let lock_directory = lock_path.parent().ok_or_else(|| { + format!( + "Usenet output lock path has no parent: {}", + lock_path.display() + ) + })?; + fs::create_dir_all(lock_directory).map_err(|error| { + format!( + "create Usenet output lock directory {}: {error}", + lock_directory.display() + ) + })?; + let lock_file = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&lock_path) + .map_err(|error| format!("open Usenet output lock {}: {error}", lock_path.display()))?; + match FileExt::try_lock(&lock_file) { + Ok(()) => Ok(Some(OutputReservation { + output, + _lock_path: lock_path, + lock_file: Some(lock_file), + })), + Err(TryLockError::WouldBlock) => Ok(None), + Err(TryLockError::Error(error)) => Err(format!( + "lock Usenet output {}: {error}", + lock_path.display() + )), + } + }) + .await + .map_err(|error| format!("reserve Usenet output: {error}"))? +} + +fn output_lock_path(output: &Path) -> Result { + let parent = output + .parent() + .ok_or_else(|| format!("Usenet output has no parent: {}", output.display()))?; + let name = output + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("Usenet output has no valid filename: {}", output.display()))?; + let mut hasher = Sha256::new(); + hasher.update(name.to_lowercase().as_bytes()); + Ok(parent + .join(".risuko-usenet-locks") + .join(format!("{}.lock", hex::encode(hasher.finalize())))) +} + +async fn output_slot_is_available(output: &Path) -> Result { + for path in [output.to_path_buf(), partial_path(output)] { + match tokio::fs::symlink_metadata(&path).await { + Ok(_) => return Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("inspect Usenet output {}: {error}", path.display())), + } + } + + let sidecar_path = resume_sidecar_path(output); + match tokio::fs::symlink_metadata(&sidecar_path).await { + Ok(metadata) => { + // Only regular files can be parsed as resume metadata. Treat + // FIFOs, devices, directories, and symlinks as occupied so + // reservation never blocks on an arbitrary filesystem object. + if !metadata.file_type().is_file() { + return Ok(false); + } + match tokio::fs::read(&sidecar_path).await { + Ok(bytes) if serde_json::from_slice::(&bytes).is_err() => Ok(true), + Ok(_) => Ok(false), + Err(error) => Err(format!( + "read Usenet resume metadata {}: {error}", + sidecar_path.display() + )), + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), + Err(error) => Err(format!( + "inspect Usenet resume metadata {}: {error}", + sidecar_path.display() + )), + } +} + +async fn persist_par2_repair_state( + assembled: &[AssembledTaskFile], + promoted_outputs: &[PathBuf], +) -> Result<(), String> { + for (index, output) in promoted_outputs.iter().enumerate() { + let Some(file) = assembled.iter().find(|file| &file.report.output == output) else { + continue; + }; + if let Err(error) = mark_par2_repaired(&file.report).await { + for output in &promoted_outputs[index..] { + if let Some(unmarked) = assembled.iter().find(|file| &file.report.output == output) + { + if !unmarked.report.complete { + let _ = tokio::fs::remove_file(&unmarked.report.output).await; + } + } + } + return Err(format!("persist PAR2 repair resume metadata: {error}")); + } + } + + for output in promoted_outputs { + let Some(file) = assembled.iter().find(|file| &file.report.output == output) else { + continue; + }; + if !file.report.complete && file.report.part_path != file.report.output { + if let Err(error) = tokio::fs::remove_file(&file.report.part_path).await { + if error.kind() != std::io::ErrorKind::NotFound { + tracing::warn!( + path = %file.report.part_path.display(), + %error, + "repaired Usenet part retained because cleanup failed" + ); + } + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::task::{UsenetTaskFile, UsenetTaskSegment}; + use crate::engine::usenet_pipeline::{manifest_sha256, ResumeSegment, ResumeSidecar}; + use sha2::{Digest, Sha256}; + use std::sync::atomic::AtomicUsize; + use std::time::Duration; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::{TcpListener, TcpStream}; + use tokio::time::timeout; + + const TEST_YENC_ARTICLE: &[u8] = b"=ybegin line=128 size=1 name=x\r\nk\r\n=yend size=1\r\n"; + + struct RecordingResolver { + credentials: HashMap, + calls: Mutex>, + } + + impl RecordingResolver { + fn calls_for(&self, profile_id: &str) -> usize { + self.calls + .lock() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .filter(|id| id.as_str() == profile_id) + .count() + } + } + + async fn wait_for_resolver_calls( + resolver: &RecordingResolver, + profile_id: &str, + expected: usize, + ) { + timeout(Duration::from_secs(3), async { + loop { + if resolver.calls_for(profile_id) >= expected { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("fake resolver did not receive the expected call"); + } + + #[async_trait::async_trait] + impl UsenetCredentialResolver for RecordingResolver { + async fn resolve(&self, profile_id: &str) -> Result, String> { + self.calls + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(profile_id.to_string()); + Ok(self.credentials.get(profile_id).cloned()) + } + } + + fn profile(id: &str, port: u16, priority: i32) -> UsenetProviderProfile { + UsenetProviderProfile { + id: id.into(), + name: id.into(), + host: "127.0.0.1".into(), + port, + security_mode: "plain".into(), + enabled: true, + priority, + max_connections: 1, + allow_plain: true, + deleted_at: None, + updated_at: None, + } + } + + fn source_for_test( + profiles: Vec, + resolver: Arc, + ) -> NntpArticleSource { + source_for_test_with_capacity( + profiles, + resolver, + Arc::new(ProviderConnectionCapacityRegistry::default()), + ) + } + + fn source_for_test_with_capacity( + profiles: Vec, + resolver: Arc, + connection_capacity: Arc, + ) -> NntpArticleSource { + NntpArticleSource { + pool: Arc::new(ProviderPool::new(profiles).unwrap()), + resolver, + connection_capacity, + profile_sessions: ProfileSessionCache::default(), + preferred_profile_id: Mutex::new(None), + active_time: ActiveTimeTracker::new(), + max_active_seconds: 60, + cancel: CancellationToken::new(), + } + } + + async fn read_command(reader: &mut BufReader) -> String { + let mut line = String::new(); + let read = reader.read_line(&mut line).await.unwrap(); + assert_ne!(read, 0, "client closed the fake NNTP connection early"); + line.trim_end_matches(['\r', '\n']).to_string() + } + + async fn greet_and_authenticate( + reader: &mut BufReader, + username: &str, + password: &str, + ) { + reader + .get_mut() + .write_all(b"200 fake NNTP server ready\r\n") + .await + .unwrap(); + assert_eq!( + read_command(reader).await, + format!("AUTHINFO USER {username}") + ); + reader + .get_mut() + .write_all(b"381 password required\r\n") + .await + .unwrap(); + assert_eq!( + read_command(reader).await, + format!("AUTHINFO PASS {password}") + ); + reader + .get_mut() + .write_all(b"281 authenticated\r\n") + .await + .unwrap(); + } + + async fn write_test_article(reader: &mut BufReader) { + reader + .get_mut() + .write_all(b"220 article follows\r\n") + .await + .unwrap(); + reader.get_mut().write_all(TEST_YENC_ARTICLE).await.unwrap(); + reader.get_mut().write_all(b".\r\n").await.unwrap(); + } + + fn credentials(username: &str, password: &str) -> UsenetCredentials { + UsenetCredentials { + username: Some(username.into()), + password: Some(password.into()), + } + } + + #[tokio::test] + async fn reuses_authenticated_connection_and_credentials() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_by_server = accepted.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + accepted_by_server.fetch_add(1, Ordering::Relaxed); + let mut reader = BufReader::new(stream); + greet_and_authenticate(&mut reader, "alice", "secret").await; + for message_id in ["one@example", "two@example"] { + assert_eq!( + read_command(&mut reader).await, + format!("ARTICLE <{message_id}>") + ); + write_test_article(&mut reader).await; + } + }); + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::from([("primary".into(), credentials("alice", "secret"))]), + calls: Mutex::new(Vec::new()), + }); + let source = source_for_test(vec![profile("primary", port, 0)], resolver.clone()); + + timeout(Duration::from_secs(3), async { + assert_eq!(source.fetch("one@example").await.unwrap().data, b"A"); + assert_eq!(source.fetch("two@example").await.unwrap().data, b"A"); + server.await.unwrap(); + }) + .await + .expect("fake NNTP reuse test timed out"); + + assert_eq!(accepted.load(Ordering::Relaxed), 1); + assert_eq!(resolver.calls_for("primary"), 1); + } + + #[tokio::test] + async fn equal_priority_profiles_keep_the_warm_stream() { + let primary = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let primary_port = primary.local_addr().unwrap().port(); + let secondary = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let secondary_port = secondary.local_addr().unwrap().port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_by_server = accepted.clone(); + let primary_server = tokio::spawn(async move { + let (stream, _) = primary.accept().await.unwrap(); + accepted_by_server.fetch_add(1, Ordering::Relaxed); + let mut reader = BufReader::new(stream); + greet_and_authenticate(&mut reader, "alice", "secret").await; + for message_id in ["one@example", "two@example"] { + assert_eq!( + read_command(&mut reader).await, + format!("ARTICLE <{message_id}>") + ); + write_test_article(&mut reader).await; + } + }); + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::from([ + ("primary".into(), credentials("alice", "secret")), + ("secondary".into(), credentials("bob", "secret")), + ]), + calls: Mutex::new(Vec::new()), + }); + let source = source_for_test( + vec![ + profile("primary", primary_port, 0), + profile("secondary", secondary_port, 0), + ], + resolver.clone(), + ); + + timeout(Duration::from_secs(3), async { + assert_eq!(source.fetch("one@example").await.unwrap().data, b"A"); + assert_eq!(source.fetch("two@example").await.unwrap().data, b"A"); + primary_server.await.unwrap(); + }) + .await + .expect("equal-priority profiles did not keep the warm stream"); + + assert_eq!(accepted.load(Ordering::Relaxed), 1); + assert_eq!(resolver.calls_for("primary"), 1); + assert_eq!(resolver.calls_for("secondary"), 0); + assert!( + timeout(Duration::from_millis(100), secondary.accept()) + .await + .is_err(), + "the equal-priority fallback was connected despite a healthy warm stream" + ); + } + + #[tokio::test] + async fn switching_profiles_releases_the_old_capacity_lease() { + let first = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let first_port = first.local_addr().unwrap().port(); + let second = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let second_port = second.local_addr().unwrap().port(); + let first_server = tokio::spawn(async move { + let (unavailable, _) = first.accept().await.unwrap(); + let mut unavailable_reader = BufReader::new(unavailable); + greet_and_authenticate(&mut unavailable_reader, "alice", "first-secret").await; + assert_eq!( + read_command(&mut unavailable_reader).await, + "ARTICLE " + ); + unavailable_reader + .get_mut() + .write_all(b"430 article unavailable\r\n") + .await + .unwrap(); + + let (available, _) = first.accept().await.unwrap(); + let mut available_reader = BufReader::new(available); + greet_and_authenticate(&mut available_reader, "alice", "first-secret").await; + assert_eq!( + read_command(&mut available_reader).await, + "ARTICLE " + ); + write_test_article(&mut available_reader).await; + }); + let second_server = tokio::spawn(async move { + let (stream, _) = second.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + greet_and_authenticate(&mut reader, "bob", "second-secret").await; + assert_eq!(read_command(&mut reader).await, "ARTICLE "); + write_test_article(&mut reader).await; + }); + let registry = Arc::new(ProviderConnectionCapacityRegistry::default()); + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::from([ + ("first".into(), credentials("alice", "first-secret")), + ("second".into(), credentials("bob", "second-secret")), + ]), + calls: Mutex::new(Vec::new()), + }); + let fallback_source = source_for_test_with_capacity( + vec![ + profile("first", first_port, 0), + profile("second", second_port, 1), + ], + resolver.clone(), + registry.clone(), + ); + + assert_eq!( + fallback_source.fetch("missing@example").await.unwrap().data, + b"A" + ); + + let waiting_source = source_for_test_with_capacity( + vec![profile("first", first_port, 0)], + resolver, + registry, + ); + assert_eq!( + timeout( + Duration::from_secs(3), + waiting_source.fetch("other@example") + ) + .await + .expect("fallback retained the previous provider capacity lease") + .unwrap() + .data, + b"A" + ); + first_server.await.unwrap(); + second_server.await.unwrap(); + } + + #[tokio::test] + async fn cancellation_during_connection_releases_the_capacity_lease() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (first_connected_tx, first_connected_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (stalled, _) = listener.accept().await.unwrap(); + let _stalled_reader = BufReader::new(stalled); + first_connected_tx.send(()).unwrap(); + + let (healthy, _) = listener.accept().await.unwrap(); + let mut healthy_reader = BufReader::new(healthy); + greet_and_authenticate(&mut healthy_reader, "alice", "secret").await; + assert_eq!( + read_command(&mut healthy_reader).await, + "ARTICLE " + ); + write_test_article(&mut healthy_reader).await; + }); + let registry = Arc::new(ProviderConnectionCapacityRegistry::default()); + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::from([("primary".into(), credentials("alice", "secret"))]), + calls: Mutex::new(Vec::new()), + }); + let cancel = CancellationToken::new(); + let mut stalled_source = source_for_test_with_capacity( + vec![profile("primary", port, 0)], + resolver.clone(), + registry.clone(), + ); + stalled_source.cancel = cancel.clone(); + let stalled_source = Arc::new(stalled_source); + let source_for_fetch = stalled_source.clone(); + let stalled_fetch = + tokio::spawn(async move { source_for_fetch.fetch("first@example").await }); + + first_connected_rx.await.unwrap(); + cancel.cancel(); + let error = timeout(Duration::from_secs(3), stalled_fetch) + .await + .expect("cancelled connection stayed blocked") + .unwrap() + .unwrap_err(); + assert!(matches!( + error, + ArticleFetchError::Failed(message) if message.contains("Download cancelled") + )); + + let healthy_source = + source_for_test_with_capacity(vec![profile("primary", port, 0)], resolver, registry); + assert_eq!( + timeout( + Duration::from_secs(3), + healthy_source.fetch("second@example") + ) + .await + .expect("cancelled connection retained the capacity lease") + .unwrap() + .data, + b"A" + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn cancellation_during_article_releases_the_capacity_lease() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (article_requested_tx, article_requested_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (stalled, _) = listener.accept().await.unwrap(); + let mut stalled_reader = BufReader::new(stalled); + greet_and_authenticate(&mut stalled_reader, "alice", "secret").await; + assert_eq!( + read_command(&mut stalled_reader).await, + "ARTICLE " + ); + article_requested_tx.send(()).unwrap(); + + let (healthy, _) = listener.accept().await.unwrap(); + let mut healthy_reader = BufReader::new(healthy); + greet_and_authenticate(&mut healthy_reader, "alice", "secret").await; + assert_eq!( + read_command(&mut healthy_reader).await, + "ARTICLE " + ); + write_test_article(&mut healthy_reader).await; + }); + let registry = Arc::new(ProviderConnectionCapacityRegistry::default()); + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::from([("primary".into(), credentials("alice", "secret"))]), + calls: Mutex::new(Vec::new()), + }); + let cancel = CancellationToken::new(); + let mut stalled_source = source_for_test_with_capacity( + vec![profile("primary", port, 0)], + resolver.clone(), + registry.clone(), + ); + stalled_source.cancel = cancel.clone(); + let stalled_source = Arc::new(stalled_source); + let source_for_fetch = stalled_source.clone(); + let stalled_fetch = + tokio::spawn(async move { source_for_fetch.fetch("first@example").await }); + + article_requested_rx.await.unwrap(); + cancel.cancel(); + let error = timeout(Duration::from_secs(3), stalled_fetch) + .await + .expect("cancelled article read stayed blocked") + .unwrap() + .unwrap_err(); + assert!(matches!( + error, + ArticleFetchError::Failed(message) if message.contains("Download cancelled") + )); + + let healthy_source = + source_for_test_with_capacity(vec![profile("primary", port, 0)], resolver, registry); + assert_eq!( + timeout( + Duration::from_secs(3), + healthy_source.fetch("second@example") + ) + .await + .expect("cancelled article retained the capacity lease") + .unwrap() + .data, + b"A" + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn reconnects_closed_session_without_reresolving_credentials() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_by_server = accepted.clone(); + let server = tokio::spawn(async move { + for message_id in ["one@example", "two@example"] { + let (stream, _) = listener.accept().await.unwrap(); + accepted_by_server.fetch_add(1, Ordering::Relaxed); + let mut reader = BufReader::new(stream); + greet_and_authenticate(&mut reader, "alice", "secret").await; + assert_eq!( + read_command(&mut reader).await, + format!("ARTICLE <{message_id}>") + ); + write_test_article(&mut reader).await; + } + }); + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::from([("primary".into(), credentials("alice", "secret"))]), + calls: Mutex::new(Vec::new()), + }); + let source = source_for_test(vec![profile("primary", port, 0)], resolver.clone()); + + timeout(Duration::from_secs(3), async { + assert_eq!(source.fetch("one@example").await.unwrap().data, b"A"); + assert_eq!(source.fetch("two@example").await.unwrap().data, b"A"); + server.await.unwrap(); + }) + .await + .expect("fake NNTP reconnect test timed out"); + + assert_eq!(accepted.load(Ordering::Relaxed), 2); + assert_eq!(resolver.calls_for("primary"), 1); + } + + #[tokio::test] + async fn unavailable_and_corrupt_articles_keep_the_authenticated_stream() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_by_server = accepted.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + accepted_by_server.fetch_add(1, Ordering::Relaxed); + let mut reader = BufReader::new(stream); + greet_and_authenticate(&mut reader, "alice", "secret").await; + + assert_eq!(read_command(&mut reader).await, "ARTICLE "); + reader + .get_mut() + .write_all(b"430 article unavailable\r\n") + .await + .unwrap(); + + assert_eq!(read_command(&mut reader).await, "ARTICLE "); + reader + .get_mut() + .write_all(b"220 article follows\r\nnot yEnc\r\n.\r\n") + .await + .unwrap(); + + assert_eq!(read_command(&mut reader).await, "ARTICLE "); + write_test_article(&mut reader).await; + }); + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::from([("primary".into(), credentials("alice", "secret"))]), + calls: Mutex::new(Vec::new()), + }); + let source = source_for_test(vec![profile("primary", port, 0)], resolver.clone()); + + timeout(Duration::from_secs(3), async { + assert!(matches!( + source.fetch("missing@example").await, + Err(ArticleFetchError::Unavailable(_)) + )); + assert!(matches!( + source.fetch("corrupt@example").await, + Err(ArticleFetchError::Unavailable(_)) + )); + assert_eq!(source.fetch("good@example").await.unwrap().data, b"A"); + server.await.unwrap(); + }) + .await + .expect("fake NNTP error-reuse test timed out"); + + assert_eq!(accepted.load(Ordering::Relaxed), 1); + assert_eq!(resolver.calls_for("primary"), 1); + } + + #[tokio::test] + async fn retained_streams_share_provider_capacity_across_tasks() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (second_accepted_tx, mut second_accepted_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (first, _) = listener.accept().await.unwrap(); + let mut first_reader = BufReader::new(first); + greet_and_authenticate(&mut first_reader, "alice", "secret").await; + assert_eq!( + read_command(&mut first_reader).await, + "ARTICLE " + ); + write_test_article(&mut first_reader).await; + + let (second, _) = listener.accept().await.unwrap(); + second_accepted_tx.send(()).unwrap(); + let mut second_reader = BufReader::new(second); + greet_and_authenticate(&mut second_reader, "alice", "secret").await; + assert_eq!( + read_command(&mut second_reader).await, + "ARTICLE " + ); + write_test_article(&mut second_reader).await; + }); + let registry = Arc::new(ProviderConnectionCapacityRegistry::default()); + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::from([("primary".into(), credentials("alice", "secret"))]), + calls: Mutex::new(Vec::new()), + }); + let primary = profile("primary", port, 0); + let source_a = source_for_test_with_capacity( + vec![primary.clone()], + resolver.clone(), + registry.clone(), + ); + let source_b = Arc::new(source_for_test_with_capacity( + vec![primary], + resolver.clone(), + registry, + )); + + assert_eq!(source_a.fetch("one@example").await.unwrap().data, b"A"); + let source_b_for_fetch = source_b.clone(); + let second_fetch = + tokio::spawn(async move { source_b_for_fetch.fetch("two@example").await }); + wait_for_resolver_calls(&resolver, "primary", 2).await; + tokio::task::yield_now().await; + assert_eq!(resolver.calls_for("primary"), 2); + assert!( + timeout(Duration::from_millis(100), &mut second_accepted_rx) + .await + .is_err(), + "a second task opened a stream before the first task released its retained lease" + ); + + drop(source_a); + assert_eq!( + timeout(Duration::from_secs(3), second_fetch) + .await + .expect("second task remained blocked after capacity was released") + .unwrap() + .unwrap() + .data, + b"A" + ); + second_accepted_rx.await.unwrap(); + server.await.unwrap(); + } + + #[tokio::test] + async fn saturated_primary_uses_an_available_backup_before_waiting() { + let primary_listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let primary_port = primary_listener.local_addr().unwrap().port(); + let backup_listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let backup_port = backup_listener.local_addr().unwrap().port(); + let primary_server = tokio::spawn(async move { + let (stream, _) = primary_listener.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + greet_and_authenticate(&mut reader, "alice", "secret").await; + assert!(matches!( + read_command(&mut reader).await.as_str(), + "ARTICLE " | "ARTICLE " + )); + write_test_article(&mut reader).await; + }); + let backup_server = tokio::spawn(async move { + let (stream, _) = backup_listener.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + greet_and_authenticate(&mut reader, "alice", "secret").await; + assert!(matches!( + read_command(&mut reader).await.as_str(), + "ARTICLE " | "ARTICLE " + )); + write_test_article(&mut reader).await; + }); + let registry = Arc::new(ProviderConnectionCapacityRegistry::default()); + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::from([ + ("primary".into(), credentials("alice", "secret")), + ("backup".into(), credentials("alice", "secret")), + ]), + calls: Mutex::new(Vec::new()), + }); + let mut primary = profile("primary", primary_port, 0); + primary.max_connections = 1; + let mut backup = profile("backup", backup_port, 0); + backup.max_connections = 1; + let source_a = Arc::new(source_for_test_with_capacity( + vec![primary.clone(), backup.clone()], + resolver.clone(), + registry.clone(), + )); + let source_b = Arc::new(source_for_test_with_capacity( + vec![primary, backup], + resolver, + registry, + )); + + let (first, second) = timeout(Duration::from_secs(3), async { + tokio::join!( + source_a.fetch("first@example"), + source_b.fetch("second@example") + ) + }) + .await + .expect("a full primary provider blocked a task despite a free backup"); + + assert_eq!(first.unwrap().data, b"A"); + assert_eq!(second.unwrap().data, b"A"); + primary_server.await.unwrap(); + backup_server.await.unwrap(); + } + + #[tokio::test] + async fn cancelling_while_waiting_for_capacity_releases_the_waiter() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (first, _) = listener.accept().await.unwrap(); + let mut first_reader = BufReader::new(first); + greet_and_authenticate(&mut first_reader, "alice", "secret").await; + assert_eq!( + read_command(&mut first_reader).await, + "ARTICLE " + ); + write_test_article(&mut first_reader).await; + + let (third, _) = listener.accept().await.unwrap(); + let mut third_reader = BufReader::new(third); + greet_and_authenticate(&mut third_reader, "alice", "secret").await; + assert_eq!( + read_command(&mut third_reader).await, + "ARTICLE " + ); + write_test_article(&mut third_reader).await; + }); + let registry = Arc::new(ProviderConnectionCapacityRegistry::default()); + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::from([("primary".into(), credentials("alice", "secret"))]), + calls: Mutex::new(Vec::new()), + }); + let primary = profile("primary", port, 0); + let source_a = source_for_test_with_capacity( + vec![primary.clone()], + resolver.clone(), + registry.clone(), + ); + let wait_cancel = CancellationToken::new(); + let mut source_b = source_for_test_with_capacity( + vec![primary.clone()], + resolver.clone(), + registry.clone(), + ); + source_b.cancel = wait_cancel.clone(); + let source_b = Arc::new(source_b); + + assert_eq!(source_a.fetch("one@example").await.unwrap().data, b"A"); + let source_b_for_fetch = source_b.clone(); + let blocked_fetch = + tokio::spawn(async move { source_b_for_fetch.fetch("two@example").await }); + wait_for_resolver_calls(&resolver, "primary", 2).await; + tokio::task::yield_now().await; + assert_eq!(resolver.calls_for("primary"), 2); + wait_cancel.cancel(); + let error = timeout(Duration::from_secs(3), blocked_fetch) + .await + .expect("cancelled task remained queued for provider capacity") + .unwrap() + .unwrap_err(); + assert!( + matches!(error, ArticleFetchError::Failed(message) if message.contains("Download cancelled")) + ); + + drop(source_b); + drop(source_a); + let source_c = source_for_test_with_capacity(vec![primary], resolver, registry); + assert_eq!( + timeout(Duration::from_secs(3), source_c.fetch("three@example")) + .await + .expect("cancelled waiter kept the provider capacity blocked") + .unwrap() + .data, + b"A" + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn failed_connect_releases_the_capacity_lease() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (failed, _) = listener.accept().await.unwrap(); + let mut failed_reader = BufReader::new(failed); + failed_reader + .get_mut() + .write_all(b"400 temporarily unavailable\r\n") + .await + .unwrap(); + + let (healthy, _) = listener.accept().await.unwrap(); + let mut healthy_reader = BufReader::new(healthy); + greet_and_authenticate(&mut healthy_reader, "alice", "secret").await; + assert_eq!( + read_command(&mut healthy_reader).await, + "ARTICLE " + ); + write_test_article(&mut healthy_reader).await; + }); + let registry = Arc::new(ProviderConnectionCapacityRegistry::default()); + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::from([("primary".into(), credentials("alice", "secret"))]), + calls: Mutex::new(Vec::new()), + }); + let primary = profile("primary", port, 0); + let failed_source = source_for_test_with_capacity( + vec![primary.clone()], + resolver.clone(), + registry.clone(), + ); + let healthy_source = source_for_test_with_capacity(vec![primary], resolver, registry); + + assert!(matches!( + failed_source.fetch("one@example").await, + Err(ArticleFetchError::Failed(_)) + )); + assert_eq!( + timeout(Duration::from_secs(3), healthy_source.fetch("two@example")) + .await + .expect("failed connection kept provider capacity leased") + .unwrap() + .data, + b"A" + ); + server.await.unwrap(); + } + + #[tokio::test] + async fn failover_keeps_cached_credentials_scoped_to_each_profile() { + let first = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let first_port = first.local_addr().unwrap().port(); + let second = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let second_port = second.local_addr().unwrap().port(); + let first_server = tokio::spawn(async move { + let (stream, _) = first.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + greet_and_authenticate(&mut reader, "alice", "first-secret").await; + assert_eq!(read_command(&mut reader).await, "ARTICLE "); + reader + .get_mut() + .write_all(b"430 article unavailable\r\n") + .await + .unwrap(); + }); + let second_server = tokio::spawn(async move { + let (stream, _) = second.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + greet_and_authenticate(&mut reader, "bob", "second-secret").await; + assert_eq!(read_command(&mut reader).await, "ARTICLE "); + write_test_article(&mut reader).await; + }); + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::from([ + ("first".into(), credentials("alice", "first-secret")), + ("second".into(), credentials("bob", "second-secret")), + ]), + calls: Mutex::new(Vec::new()), + }); + let source = source_for_test( + vec![ + profile("first", first_port, 0), + profile("second", second_port, 1), + ], + resolver.clone(), + ); + + timeout(Duration::from_secs(3), async { + assert_eq!(source.fetch("one@example").await.unwrap().data, b"A"); + first_server.await.unwrap(); + second_server.await.unwrap(); + }) + .await + .expect("fake NNTP failover test timed out"); + + assert_eq!(resolver.calls_for("first"), 1); + assert_eq!(resolver.calls_for("second"), 1); + } + + #[test] + fn corrupt_articles_become_repairable_holes_after_provider_failover() { + let error = article_fetch_error(NntpError::ArticleCorrupt { + message: "pcrc32 mismatch".into(), + }); + + assert!( + matches!(error, ArticleFetchError::Unavailable(message) if message.contains("invalid yEnc")) + ); + } + + #[test] + fn insufficient_par2_recovery_keeps_a_structured_task_summary() { + let unavailable = vec!["archive.part1.rar:[3, 6]".to_string()]; + let error = UsenetDownloadError::from_par2_error( + Par2Error::InsufficientRecovery { + needed: 184, + available: 62, + }, + &unavailable, + ); + + assert_eq!( + error.repair_failure(), + Some(&UsenetRepairFailure { + needed_blocks: 184, + available_blocks: 62, + partials_retained: true, + }) + ); + assert_eq!( + error.to_string(), + "PAR2 recovery is insufficient: need 184 blocks, have 62; unavailable segments: archive.part1.rar:[3, 6]" + ); + } + + #[test] + fn task_article_bytes_uses_the_manifest_segment_byte_unit() { + let metadata = UsenetTaskData { + files: vec![UsenetTaskFile { + name: "sample.bin".into(), + subject: "sample.bin yEnc".into(), + groups: vec!["alt.binaries.example".into()], + segments: vec![ + UsenetTaskSegment { + number: 1, + bytes: 17, + message_id: "one".into(), + }, + UsenetTaskSegment { + number: 2, + bytes: 19, + message_id: "two".into(), + }, + ], + }], + ..Default::default() + }; + + assert_eq!(task_article_bytes(&metadata).unwrap(), 36); + } + + #[tokio::test] + async fn resumes_a_finalized_output_before_applying_collision_naming() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let segments = vec![NzbSegment { + number: 1, + bytes: 2, + message_id: "one".into(), + }]; + let mut sidecar = ResumeSidecar::new(manifest_sha256(&segments)); + sidecar.expected_size = Some(2); + sidecar.completed_bytes = 2; + sidecar.completed_segments.insert(1); + sidecar.segment_receipts.insert( + 1, + ResumeSegment { + offset: 0, + length: 2, + sha256: hex::encode(Sha256::digest(b"OK")), + }, + ); + sidecar + .save_atomic(&resume_sidecar_path(&output)) + .await + .unwrap(); + tokio::fs::write(&output, b"OK").await.unwrap(); + + let reservation = reserve_output_path(dir.path(), "sample.bin", &segments) + .await + .unwrap(); + + assert_eq!(reservation.output, output); + } + + #[tokio::test] + async fn malformed_resume_sidecar_is_replaced_by_a_fresh_reservation() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let segments = vec![NzbSegment { + number: 1, + bytes: 2, + message_id: "one".into(), + }]; + tokio::fs::write(resume_sidecar_path(&output), b"{truncated") + .await + .unwrap(); + + let reservation = reserve_output_path(dir.path(), "sample.bin", &segments) + .await + .unwrap(); + + assert_eq!(reservation.output, output); + } + + #[tokio::test] + async fn resume_sidecar_directory_occupies_output_slot() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + tokio::fs::create_dir(resume_sidecar_path(&output)) + .await + .unwrap(); + + assert!(!output_slot_is_available(&output).await.unwrap()); + } + + #[cfg(unix)] + #[tokio::test] + async fn broken_resume_sidecar_symlink_occupies_output_slot() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("sample.bin"); + let sidecar = resume_sidecar_path(&output); + std::os::unix::fs::symlink(dir.path().join("missing-sidecar"), &sidecar).unwrap(); + + assert!(!output_slot_is_available(&output).await.unwrap()); + } + + #[tokio::test] + async fn reserves_a_distinct_output_for_concurrent_tasks() { + let dir = tempfile::tempdir().unwrap(); + let segments = vec![NzbSegment { + number: 1, + bytes: 2, + message_id: "one".into(), + }]; + + let first = reserve_output_path(dir.path(), "sample.bin", &segments) + .await + .unwrap(); + let second = reserve_output_path(dir.path(), "sample.bin", &segments) + .await + .unwrap(); + + assert_eq!(first.output, dir.path().join("sample.bin")); + assert_eq!(second.output, dir.path().join("sample (1).bin")); + } + + #[tokio::test] + async fn reserves_casefolded_names_as_one_output_slot() { + let dir = tempfile::tempdir().unwrap(); + let segments = vec![NzbSegment { + number: 1, + bytes: 2, + message_id: "one".into(), + }]; + + let first = reserve_output_path(dir.path(), "Sample.bin", &segments) + .await + .unwrap(); + let second = reserve_output_path(dir.path(), "sample.bin", &segments) + .await + .unwrap(); + + assert_eq!(first.output, dir.path().join("Sample.bin")); + assert_eq!(second.output, dir.path().join("sample (1).bin")); + } + + #[test] + fn active_time_limit_is_checked_during_assembly() { + let tracker = ActiveTimeTracker { + started_at: Instant::now() - Duration::from_secs(2), + credential_wait: Arc::new(Mutex::new(Duration::ZERO)), + }; + + assert!(ensure_usenet_active_time(&tracker, 1).is_err()); + } + + #[test] + fn capacity_wait_reports_the_archive_limit_when_no_active_time_remains() { + let resolver = Arc::new(RecordingResolver { + credentials: HashMap::new(), + calls: Mutex::new(Vec::new()), + }); + let mut source = source_for_test(vec![profile("primary", 1, 0)], resolver); + source.max_active_seconds = 1; + source.active_time = ActiveTimeTracker { + started_at: Instant::now() - Duration::from_secs(2), + credential_wait: Arc::new(Mutex::new(Duration::ZERO)), + }; + + assert!(matches!( + source.remaining_active_time(), + Err(NntpError::Protocol { message, .. }) if message.contains("archive limit") + )); + } + + #[test] + fn credential_wait_does_not_consume_active_time() { + let tracker = ActiveTimeTracker::new(); + tracker.record_credential_wait(Duration::from_secs(60)); + + assert_eq!(tracker.active_elapsed(), Duration::ZERO); + } + + #[test] + fn task_cleanup_override_wins_over_global_default() { + let metadata = UsenetTaskData { + options: crate::engine::task::UsenetTaskOptions { + cleanup_mode: Some("delete-par2".into()), + ..Default::default() + }, + ..Default::default() + }; + let mut options = Map::new(); + options.insert( + "usenet-cleanup-mode".into(), + Value::String("delete-par2-and-archives".into()), + ); + + assert_eq!( + cleanup_mode_for_task(&metadata, &options), + CleanupMode::DeletePar2 + ); + } + + #[test] + fn archive_volume_cleanup_is_explicitly_unsupported_without_extraction_verification() { + let metadata = UsenetTaskData::default(); + let mut options = Map::new(); + options.insert( + "usenet-cleanup-mode".into(), + Value::String("delete-par2-and-volumes".into()), + ); + assert_eq!( + cleanup_mode_for_task(&metadata, &options), + CleanupMode::DeletePar2 + ); + } + + #[test] + fn cleanup_inputs_include_final_and_partial_archive_paths_only() { + let dir = tempfile::tempdir().unwrap(); + let par2_output = dir.path().join("release.vol00+01.par2"); + let rar_output = dir.path().join("release.part01.rar"); + let par2_part = partial_path(&par2_output); + let rar_part = partial_path(&rar_output); + let par2_sidecar = resume_sidecar_path(&par2_output); + let rar_sidecar = resume_sidecar_path(&rar_output); + let lock = fs::File::create(dir.path().join("lock")).unwrap(); + let assembled = vec![ + AssembledTaskFile { + index: 0, + name: "release.vol00+01.par2".into(), + is_parity: true, + report: AssemblyReport { + output: par2_output.clone(), + part_path: par2_part.clone(), + sidecar_path: par2_sidecar.clone(), + manifest_sha256: String::new(), + expected_size: None, + completed_bytes: 0, + unavailable_segments: Vec::new(), + complete: true, + }, + _reservation: OutputReservation { + output: par2_output, + _lock_path: dir.path().join("lock"), + lock_file: Some(lock), + }, + }, + AssembledTaskFile { + index: 1, + name: "release.part01.rar".into(), + is_parity: false, + report: AssemblyReport { + output: rar_output.clone(), + part_path: rar_part.clone(), + sidecar_path: rar_sidecar.clone(), + manifest_sha256: String::new(), + expected_size: None, + completed_bytes: 0, + unavailable_segments: Vec::new(), + complete: true, + }, + _reservation: OutputReservation { + output: rar_output, + _lock_path: dir.path().join("lock-2"), + lock_file: Some(fs::File::create(dir.path().join("lock-2")).unwrap()), + }, + }, + ]; + + let (par2, archives) = cleanup_inputs(&assembled, true, &[]); + assert!(par2.contains(&par2_part)); + assert!(archives.contains(&rar_part)); + assert!(!par2.contains(&par2_sidecar)); + assert!(!archives.contains(&rar_sidecar)); + } + + #[test] + fn cleanup_inputs_keeps_archive_volumes_until_extraction_is_verified() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("release.part01.rar"); + let lock = fs::File::create(dir.path().join("lock")).unwrap(); + let assembled = vec![AssembledTaskFile { + index: 0, + name: "release.part01.rar".into(), + is_parity: false, + report: AssemblyReport { + output: output.clone(), + part_path: partial_path(&output), + sidecar_path: resume_sidecar_path(&output), + manifest_sha256: String::new(), + expected_size: None, + completed_bytes: 0, + unavailable_segments: Vec::new(), + complete: true, + }, + _reservation: OutputReservation { + output, + _lock_path: dir.path().join("lock"), + lock_file: Some(lock), + }, + }]; + + let (_, archives) = cleanup_inputs(&assembled, false, &[]); + assert!(archives.is_empty()); + } + + #[test] + fn cleanup_inputs_preserves_incomplete_parity_receipts() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("release.vol00+01.par2"); + let lock = fs::File::create(dir.path().join("lock")).unwrap(); + let assembled = vec![AssembledTaskFile { + index: 0, + name: "release.vol00+01.par2".into(), + is_parity: true, + report: AssemblyReport { + output: output.clone(), + part_path: partial_path(&output), + sidecar_path: resume_sidecar_path(&output), + manifest_sha256: String::new(), + expected_size: None, + completed_bytes: 0, + unavailable_segments: vec![2], + complete: false, + }, + _reservation: OutputReservation { + output, + _lock_path: dir.path().join("lock"), + lock_file: Some(lock), + }, + }]; + + let (par2, _) = cleanup_inputs(&assembled, true, &[]); + assert!(par2.is_empty()); + } + + #[test] + fn cleanup_inputs_accepts_durably_promoted_repaired_archive() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("release.part01.rar"); + let lock = fs::File::create(dir.path().join("lock")).unwrap(); + let assembled = vec![AssembledTaskFile { + index: 0, + name: "release.part01.rar".into(), + is_parity: false, + report: AssemblyReport { + output: output.clone(), + part_path: partial_path(&output), + sidecar_path: resume_sidecar_path(&output), + manifest_sha256: String::new(), + expected_size: Some(10), + completed_bytes: 4, + unavailable_segments: vec![2], + complete: false, + }, + _reservation: OutputReservation { + output: output.clone(), + _lock_path: dir.path().join("lock"), + lock_file: Some(lock), + }, + }]; + + let (_, archives) = cleanup_inputs(&assembled, true, &[output]); + assert_eq!(archives.len(), 2); + } + + #[test] + fn output_reservation_keeps_lock_file_on_drop() { + let dir = tempfile::tempdir().unwrap(); + let lock_path = dir.path().join("reservation.lock"); + let reservation = OutputReservation { + output: dir.path().join("output"), + _lock_path: lock_path.clone(), + lock_file: Some(fs::File::create(&lock_path).unwrap()), + }; + assert!(lock_path.exists()); + drop(reservation); + assert!(lock_path.exists()); + } +} diff --git a/src-tauri/risuko-napi/src/lib.rs b/src-tauri/risuko-napi/src/lib.rs index 7bd17e49..d72b32d5 100644 --- a/src-tauri/risuko-napi/src/lib.rs +++ b/src-tauri/risuko-napi/src/lib.rs @@ -61,6 +61,8 @@ pub async fn start_engine(config: Option) -> Result<()> { let system_config = load_config(&config_dir.join("system.json"), defaults::system_defaults()); let user_config = load_config(&config_dir.join("user.json"), defaults::user_defaults()); + risuko_engine::engine::set_file_usenet_credential_resolver(config_dir.clone()).await; + let mut options = EngineOptions::from_config(&system_config, &user_config); if let Some(port) = config.as_ref().and_then(|c| c.rpc_port) { diff --git a/src-tauri/src/cli/headless.rs b/src-tauri/src/cli/headless.rs index 549f3ab1..1d617f65 100644 --- a/src-tauri/src/cli/headless.rs +++ b/src-tauri/src/cli/headless.rs @@ -29,6 +29,7 @@ pub async fn start_headless_engine( let config_dir = get_config_dir(); std::fs::create_dir_all(&config_dir)?; + risuko_engine::engine::set_file_usenet_credential_resolver(config_dir.clone()).await; let system_config = load_config(&config_dir.join("system.json"), defaults::system_defaults()); let user_config = load_config(&config_dir.join("user.json"), defaults::user_defaults()); diff --git a/src-tauri/src/commands/engine_cmds.rs b/src-tauri/src/commands/engine_cmds.rs index 2d5fdbf5..b1eb4e63 100644 --- a/src-tauri/src/commands/engine_cmds.rs +++ b/src-tauri/src/commands/engine_cmds.rs @@ -526,6 +526,94 @@ async fn add_metalink_by_path_inner(path: &str, options: Option) -> Resul manager.add_metalink_task(bytes, options).await } +async fn add_nzb_by_path_inner(path: &str, options: Option) -> Result { + let path = path.trim(); + let fs_path = Path::new(path); + let is_nzb = fs_path + .extension() + .and_then(|value| value.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("nzb")) + == Some(true); + if path.is_empty() || !is_nzb { + return Err("NZB file (.nzb) required".to_string()); + } + const MAX_NZB_BYTES: u64 = 16 * 1024 * 1024; + let meta = tokio::fs::metadata(fs_path) + .await + .map_err(|e| e.to_string())?; + if meta.len() > MAX_NZB_BYTES { + return Err("NZB file too large".to_string()); + } + let bytes = tokio::fs::read(fs_path).await.map_err(|e| e.to_string())?; + if bytes.is_empty() { + return Err("NZB payload is empty".to_string()); + } + let mut options = match options.unwrap_or(Value::Object(Map::new())) { + Value::Object(map) => map, + _ => Map::new(), + }; + options.insert( + "usenet-source-name".to_string(), + Value::String( + fs_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("download.nzb") + .to_string(), + ), + ); + let manager = engine::get_manager().await.ok_or("Engine not running")?; + manager.add_nzb_task(bytes, options).await +} + +fn is_nzb_url(uri: &str) -> bool { + let Some(scheme_end) = uri.find("://") else { + return false; + }; + let scheme = &uri[..scheme_end]; + if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") { + return false; + } + let path = uri.split(['?', '#']).next().unwrap_or(uri); + path.rsplit('/') + .next() + .unwrap_or("") + .to_ascii_lowercase() + .ends_with(".nzb") +} + +async fn fetch_nzb_url(uri: &str, options: &Map) -> Result, String> { + engine::http::fetch_for_nzb(uri, options).await +} + +#[tauri::command] +pub async fn add_nzbs_by_paths( + _handle: AppHandle, + _state: tauri::State<'_, crate::state::AppState>, + paths: Vec, + options: Option, +) -> Result, String> { + if paths.is_empty() { + return Err("NZB file (.nzb) required".to_string()); + } + let mut results = Vec::with_capacity(paths.len()); + for path in paths.iter() { + match add_nzb_by_path_inner(path, options.clone()).await { + Ok(gid) => results.push(BatchAddResult { + path: path.clone(), + gid: Some(gid), + error: None, + }), + Err(err) => results.push(BatchAddResult { + path: path.clone(), + gid: None, + error: Some(err), + }), + } + } + Ok(results) +} + #[tauri::command] pub async fn add_metalinks_by_paths( _handle: AppHandle, @@ -556,7 +644,7 @@ pub async fn add_metalinks_by_paths( fn is_plain_http_mirror_uri(uri: &str, options: &Map) -> bool { let is_http = uri.starts_with("http://") || uri.starts_with("https://"); - if !is_http { + if !is_http || is_nzb_url(uri) { return false; } if torrent::is_magnet_uri(uri) @@ -601,6 +689,12 @@ pub async fn add_uri( }; let manager = engine::get_manager().await.ok_or("Engine not running")?; + let global_options = manager + .get_global_option() + .await + .as_object() + .cloned() + .unwrap_or_default(); // Mirror group let mut distinct_outs = out_list @@ -700,6 +794,16 @@ pub async fn add_uri( // Check if this is a magnet link let result = if torrent::is_magnet_uri(uri) { manager.add_magnet_task(uri, task_options).await + } else if is_nzb_url(uri) { + // URL imports happen before the task is created, so explicitly + // merge global defaults to preserve proxy, headers, cookies, and + // other HTTP request settings for this fetch. + let mut fetch_options = global_options.clone(); + fetch_options.extend(task_options.clone()); + match fetch_nzb_url(uri, &fetch_options).await { + Ok(bytes) => manager.add_nzb_task(bytes, task_options).await, + Err(error) => Err(error), + } } else if is_m3u8 { manager.add_m3u8_task(uri, task_options).await } else if engine::ed2k::is_ed2k_uri(uri) { @@ -1429,4 +1533,25 @@ mod tests { &opts )); } + + #[test] + fn nzb_url_route_only_matches_http() { + assert!(is_nzb_url("http://example.com/file.nzb")); + assert!(is_nzb_url("https://example.com/file.NZB?token=abc")); + assert!(!is_nzb_url("ftp://example.com/file.nzb")); + assert!(!is_nzb_url("nntp://example.com/file.nzb")); + } + + #[test] + fn nzb_urls_are_not_grouped_as_http_mirrors() { + let opts = Map::new(); + assert!(!is_plain_http_mirror_uri( + "https://one.example/file.nzb", + &opts + )); + assert!(!is_plain_http_mirror_uri( + "https://two.example/file.nzb?token=x", + &opts + )); + } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a5e4db8e..50e099a4 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -13,4 +13,5 @@ pub mod rss_cmds; pub mod share_cmds; pub mod stats_cmds; pub mod upload_cmds; +pub mod usenet_cmds; pub mod vault_cmds; diff --git a/src-tauri/src/commands/usenet_cmds.rs b/src-tauri/src/commands/usenet_cmds.rs new file mode 100644 index 00000000..3fbc9a12 --- /dev/null +++ b/src-tauri/src/commands/usenet_cmds.rs @@ -0,0 +1,410 @@ +//! Usenet provider commands + +use fs4::FileExt; +use risuko_engine::engine::usenet::{UsenetCredentialResolver, UsenetProviderProfile}; +use risuko_engine::engine::usenet_transport::NntpConnection; +use serde_json::{json, Map, Value}; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tauri::State; + +use crate::state::AppState; + +fn credential_key(profile_id: &str) -> String { + format!("usenet:{profile_id}") +} + +pub struct VaultCredentialResolver { + vault: Arc, + fallback: risuko_engine::engine::FileUsenetCredentialResolver, +} + +impl VaultCredentialResolver { + pub fn with_config_dir( + vault: Arc, + config_dir: PathBuf, + ) -> Self { + Self { + vault, + fallback: risuko_engine::engine::FileUsenetCredentialResolver::new(config_dir), + } + } +} + +#[async_trait::async_trait] +impl risuko_engine::engine::usenet::UsenetCredentialResolver for VaultCredentialResolver { + async fn resolve( + &self, + profile_id: &str, + ) -> Result, String> { + if self.vault.enabled() { + match self.vault.get(&credential_key(profile_id)) { + Ok(Some(value)) => { + let username = value + .get("username") + .and_then(|value| value.as_str()) + .map(str::to_string); + let password = value + .get("password") + .and_then(|value| value.as_str()) + .map(str::to_string); + return Ok(Some(risuko_engine::engine::usenet::UsenetCredentials { + username, + password, + })); + } + Ok(None) => {} + Err(error) => { + tracing::warn!( + "Failed to load Usenet credentials for {profile_id}: {error}; trying fallback" + ); + } + } + } + self.fallback.resolve(profile_id).await + } +} + +fn load_fallback(path: &Path) -> Result, String> { + match std::fs::read_to_string(path) { + Ok(data) => serde_json::from_str(&data).map_err(|e| e.to_string()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()), + Err(error) => Err(error.to_string()), + } +} + +static FALLBACK_MUTEX: std::sync::LazyLock> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(())); +static FALLBACK_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn with_fallback_lock( + path: &Path, + operation: impl FnOnce() -> Result, +) -> Result { + let _process_guard = FALLBACK_MUTEX + .lock() + .map_err(|_| "Credential fallback lock poisoned".to_string())?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let lock_path = path.with_file_name(format!( + ".{}.lock", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("credentials") + )); + let lock_file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|e| e.to_string())?; + FileExt::lock(&lock_file).map_err(|e| e.to_string())?; + let result = operation(); + let _ = FileExt::unlock(&lock_file); + result +} + +fn write_fallback_atomic(path: &Path, data: &str) -> Result<(), String> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("credentials.json"); + let temp_path = parent.join(format!( + ".{name}.tmp-{}-{}", + std::process::id(), + FALLBACK_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let result = (|| { + let mut options = OpenOptions::new(); + options.create_new(true).write(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temp_path).map_err(|e| e.to_string())?; + file.write_all(data.as_bytes()).map_err(|e| e.to_string())?; + file.sync_all().map_err(|e| e.to_string())?; + drop(file); + set_owner_only_permissions(&temp_path)?; + replace_fallback_file(&temp_path, path)?; + sync_fallback_parent(parent)?; + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temp_path); + } + result +} + +#[cfg(unix)] +fn sync_fallback_parent(parent: &Path) -> Result<(), String> { + OpenOptions::new() + .read(true) + .open(parent) + .and_then(|directory| directory.sync_all()) + .or_else(|error| { + if matches!( + error.kind(), + std::io::ErrorKind::Unsupported | std::io::ErrorKind::InvalidInput + ) { + return Ok(()); + } + Err(error) + }) + .map_err(|error| format!("sync credential fallback directory: {error}")) +} + +#[cfg(not(unix))] +fn sync_fallback_parent(_parent: &Path) -> Result<(), String> { + Ok(()) +} + +#[cfg(unix)] +fn set_owner_only_permissions(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, PermissionsExt::from_mode(0o600)).map_err(|e| e.to_string()) +} + +#[cfg(windows)] +fn set_owner_only_permissions(path: &Path) -> Result<(), String> { + let username = std::env::var("USERNAME") + .map_err(|_| "Windows user name is unavailable for credential ACL".to_string())?; + let status = std::process::Command::new("icacls") + .arg(path) + .args(["/inheritance:r", "/grant:r"]) + .arg(format!("{username}:F")) + .status() + .map_err(|e| format!("apply credential ACL: {e}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("icacls exited with status {status}")) + } +} + +#[cfg(not(any(unix, windows)))] +fn set_owner_only_permissions(_path: &Path) -> Result<(), String> { + Ok(()) +} + +#[cfg(windows)] +fn replace_fallback_file(temp: &Path, path: &Path) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source = temp + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let result = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + Err(std::io::Error::last_os_error().to_string()) + } else { + Ok(()) + } +} + +#[cfg(not(windows))] +fn replace_fallback_file(temp: &Path, path: &Path) -> Result<(), String> { + std::fs::rename(temp, path).map_err(|e| e.to_string()) +} + +fn save_fallback(path: &Path, profile_id: &str, credentials: &Value) -> Result<(), String> { + with_fallback_lock(path, || { + let mut entries = load_fallback(path)?; + entries.insert(profile_id.to_string(), credentials.clone()); + let data = serde_json::to_string_pretty(&entries).map_err(|e| e.to_string())?; + write_fallback_atomic(path, &data) + }) +} + +fn remove_fallback(path: &Path, profile_id: &str) -> Result<(), String> { + with_fallback_lock(path, || { + let mut entries = load_fallback(path)?; + entries.remove(profile_id); + if entries.is_empty() { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } + } else { + let data = serde_json::to_string_pretty(&entries).map_err(|e| e.to_string())?; + write_fallback_atomic(path, &data) + } + }) +} + +fn resolver_for_state(state: &AppState) -> Result { + let config_dir = state + .config + .lock() + .map_err(|_| "Configuration lock poisoned".to_string())? + .config_dir() + .to_path_buf(); + Ok(VaultCredentialResolver::with_config_dir( + state.vault.clone(), + config_dir, + )) +} + +#[tauri::command] +pub fn usenet_save_credentials( + state: State<'_, AppState>, + profile_id: String, + username: Option, + password: Option, +) -> Result<(), String> { + if profile_id.trim().is_empty() { + return Err("Usenet profile id is required".into()); + } + let credentials = json!({ + "username": username.filter(|value| !value.is_empty()), + "password": password.filter(|value| !value.is_empty()), + }); + let config_dir = state + .config + .lock() + .map_err(|_| "Configuration lock poisoned".to_string())? + .config_dir() + .to_path_buf(); + let fallback_path = risuko_engine::engine::usenet_credential_fallback_path(&config_dir); + if state.vault.enabled() { + match state.vault.put(&credential_key(&profile_id), &credentials) { + Ok(()) => { + if let Err(error) = remove_fallback(&fallback_path, &profile_id) { + tracing::warn!( + "Failed to clear stale Usenet credential fallback for {profile_id}: {error}" + ); + } + return Ok(()); + } + Err(error) => tracing::warn!( + "Failed to store Usenet credentials in vault for {profile_id}: {error}; using fallback" + ), + } + } + save_fallback(&fallback_path, &profile_id, &credentials) +} + +#[tauri::command] +pub fn usenet_remove_credentials( + state: State<'_, AppState>, + profile_id: String, +) -> Result<(), String> { + let config_dir = state + .config + .lock() + .map_err(|_| "Configuration lock poisoned".to_string())? + .config_dir() + .to_path_buf(); + let fallback_path = risuko_engine::engine::usenet_credential_fallback_path(&config_dir); + let vault_error = if state.vault.enabled() { + state.vault.remove(&credential_key(&profile_id)).err() + } else { + None + }; + let fallback_result = remove_fallback(&fallback_path, &profile_id); + if let Some(error) = vault_error { + return Err(error); + } + fallback_result +} + +#[tauri::command] +pub fn usenet_has_credentials( + state: State<'_, AppState>, + profile_id: String, +) -> Result { + let config_dir = state + .config + .lock() + .map_err(|_| "Configuration lock poisoned".to_string())? + .config_dir() + .to_path_buf(); + if state.vault.enabled() { + match state.vault.get(&credential_key(&profile_id)) { + Ok(Some(_)) => return Ok(true), + Ok(None) => {} + Err(error) => tracing::warn!( + "Failed to inspect Usenet credentials for {profile_id}: {error}; trying fallback" + ), + } + } + let fallback_path = risuko_engine::engine::usenet_credential_fallback_path(&config_dir); + Ok(load_fallback(&fallback_path)?.contains_key(&profile_id)) +} + +#[tauri::command] +pub async fn usenet_test_profile( + state: State<'_, AppState>, + profile: UsenetProviderProfile, +) -> Result { + let credentials = resolver_for_state(&state)?.resolve(&profile.id).await?; + let _capacity_lease = match risuko_engine::engine::get_manager().await { + Some(manager) => Some( + manager + .try_acquire_usenet_profile_connection(&profile)? + .ok_or_else(|| { + "The provider's configured connection capacity is currently in use".to_string() + })?, + ), + None => None, + }; + let mut connection = NntpConnection::connect(&profile, credentials) + .await + .map_err(|error| error.to_string())?; + let _ = connection + .capabilities() + .await + .map_err(|error| error.to_string())?; + Ok("ok".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn fallback_credentials_round_trip_and_remove() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("usenet-credentials.json"); + let credentials = json!({"username": "alice", "password": "secret"}); + + save_fallback(&path, "primary", &credentials).unwrap(); + let entries = load_fallback(&path).unwrap(); + assert_eq!(entries.get("primary"), Some(&credentials)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + remove_fallback(&path, "primary").unwrap(); + assert!(!path.exists()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c1e8f693..528cac7f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -267,19 +267,23 @@ pub fn run() { risuko_engine::engine::should_start_embedded_engine(&config) }; - // Push the live Tauri event sink into the upload manager so upload - // events reach the frontend (we built it with a NoopEventSink before - // AppHandle was available). Runs regardless of whether the embedded - // engine auto-starts, so the sink-config UI still gets upload events - // when the user starts the engine later let event_sink_clone = event_sink.clone(); let upload_mgr = app.state::().upload_sinks.clone(); upload_mgr.set_event_sink(event_sink_clone.clone()); + let vault = app.state::().vault.clone(); + let config_dir = app + .state::() + .config + .lock() + .map_err(|_| "Configuration lock poisoned")? + .config_dir() + .to_path_buf(); + tauri::async_runtime::block_on(risuko_engine::engine::set_usenet_credential_resolver( + std::sync::Arc::new( + commands::usenet_cmds::VaultCredentialResolver::with_config_dir(vault, config_dir), + ), + )); - // Inject SFTP/FTP/WebDAV/S3 secrets stored in the OS keychain - // back into the upload manager. The on-disk records omit them - // (`skip_serializing` on the protocol Configs) so without this - // pass the user has to re-enter every password after a restart { let vault = app.state::().vault.clone(); tauri::async_runtime::block_on(commands::upload_cmds::rehydrate_upload_sinks( @@ -442,6 +446,7 @@ pub fn run() { commands::engine_cmds::add_torrent_by_path, commands::engine_cmds::add_torrents_by_paths, commands::engine_cmds::add_metalinks_by_paths, + commands::engine_cmds::add_nzbs_by_paths, commands::engine_cmds::resolve_magnet, commands::engine_cmds::evaluate_low_speed_tasks, commands::engine_cmds::plan_auto_retry, @@ -538,6 +543,10 @@ pub fn run() { commands::vault_cmds::vault_put_credential, commands::vault_cmds::vault_get_credential, commands::vault_cmds::vault_remove_credential, + commands::usenet_cmds::usenet_save_credentials, + commands::usenet_cmds::usenet_remove_credentials, + commands::usenet_cmds::usenet_has_credentials, + commands::usenet_cmds::usenet_test_profile, commands::share_cmds::share_start_send, commands::share_cmds::share_start_receive, commands::share_cmds::share_cancel, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0a50c4df..5e195f69 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -51,6 +51,11 @@ "ext": ["meta4", "metalink"], "mimeType": "application/metalink4+xml", "description": "Metalink file" + }, + { + "ext": ["nzb"], + "mimeType": "application/x-nzb", + "description": "NZB file" } ], "macOS": { diff --git a/src/renderer/api/Api.ts b/src/renderer/api/Api.ts index 644102cf..34a993bb 100644 --- a/src/renderer/api/Api.ts +++ b/src/renderer/api/Api.ts @@ -135,6 +135,26 @@ export default class Api { ); } + saveUsenetCredentials( + profileId: string, + username?: string, + password?: string, + ) { + return invoke("usenet_save_credentials", { profileId, username, password }); + } + + removeUsenetCredentials(profileId: string) { + return invoke("usenet_remove_credentials", { profileId }); + } + + hasUsenetCredentials(profileId: string) { + return invoke("usenet_has_credentials", { profileId }); + } + + testUsenetProfile(profile: unknown) { + return invoke("usenet_test_profile", { profile }); + } + getOption(params: { gid: string } = { gid: "" }) { const { gid } = params; return invoke("get_option_engine", { gid }).then((data) => @@ -337,6 +357,23 @@ export default class Api { ); } + addNzbsByPaths(params: { + paths: string[]; + options?: Record; + }) { + const { paths, options } = params; + const { usenetArchiveLimits, ...scalarOptions } = options || {}; + const engineOptions: Record = + formatOptionsForEngine(scalarOptions); + if (usenetArchiveLimits !== undefined) { + engineOptions["usenet-archive-limits"] = usenetArchiveLimits; + } + return invoke<{ path: string; gid: string | null; error: string | null }[]>( + "add_nzbs_by_paths", + { paths, options: engineOptions }, + ); + } + fetchWaitingTaskList( params: { offset?: number; num?: number; keys?: string[] } = {}, ) { diff --git a/src/renderer/components/Native/EngineClient.vue b/src/renderer/components/Native/EngineClient.vue index 2c450310..8194d7f7 100644 --- a/src/renderer/components/Native/EngineClient.vue +++ b/src/renderer/components/Native/EngineClient.vue @@ -5,6 +5,7 @@ + + diff --git a/src/renderer/components/Stats/StatsPage.vue b/src/renderer/components/Stats/StatsPage.vue index 6c246a3c..bc2045a5 100644 --- a/src/renderer/components/Stats/StatsPage.vue +++ b/src/renderer/components/Stats/StatsPage.vue @@ -377,6 +377,7 @@ const protocolLabel = (protocol: string) => { const labels: Record = { http: "HTTP", torrent: "BitTorrent", + usenet: "Usenet", ftp: "FTP", ed2k: "ED2K", m3u8: "M3U8", diff --git a/src/renderer/components/Task/AddTask.vue b/src/renderer/components/Task/AddTask.vue index 2ab86e2f..0371c2a0 100644 --- a/src/renderer/components/Task/AddTask.vue +++ b/src/renderer/components/Task/AddTask.vue @@ -757,8 +757,8 @@ export default { multiple: true, filters: [ { - name: "Torrent / Metalink", - extensions: ["torrent", "meta4", "metalink"], + name: "Torrent / Metalink / NZB", + extensions: ["torrent", "meta4", "metalink", "nzb"], }, ], }); @@ -892,6 +892,7 @@ export default { const metalinkItems = items.filter( (it) => it.kind === "metalink" && it.path, ); + const nzbItems = items.filter((it) => it.kind === "nzb" && it.path); const uriItems = items.filter( (it) => it.kind === "uri" || it.kind === "magnet", ); @@ -937,6 +938,17 @@ export default { failCount += r.fail; } + if (nzbItems.length > 0) { + const r = await this.submitPathBatch(nzbItems, () => + taskStore.addNzbs({ + paths: nzbItems.map((it) => it.path as string), + options: this.buildSharedOptions(), + }), + ); + okCount += r.ok; + failCount += r.fail; + } + for (const it of uriItems) { try { const uri = it.uri as string; diff --git a/src/renderer/components/Task/BatchItemCard.vue b/src/renderer/components/Task/BatchItemCard.vue index bae997fc..a109f025 100644 --- a/src/renderer/components/Task/BatchItemCard.vue +++ b/src/renderer/components/Task/BatchItemCard.vue @@ -116,7 +116,7 @@ -
+
{{ item.path }}
@@ -244,7 +244,11 @@ export default { }, computed: { kindIcon() { - if (this.item.kind === "torrent" || this.item.kind === "metalink") { + if ( + this.item.kind === "torrent" || + this.item.kind === "metalink" || + this.item.kind === "nzb" + ) { return FileArchive; } if (this.item.kind === "magnet") { diff --git a/src/renderer/components/TaskDetail/TaskGeneral.vue b/src/renderer/components/TaskDetail/TaskGeneral.vue index 61ee62bc..31c58ae5 100644 --- a/src/renderer/components/TaskDetail/TaskGeneral.vue +++ b/src/renderer/components/TaskDetail/TaskGeneral.vue @@ -57,7 +57,7 @@
{{ $t('task.task-error-info') }} {{ task.errorCode }} {{ task.errorMessage }}{{ task.errorCode }} {{ taskErrorMessage }}
@@ -112,6 +112,7 @@ import { bytesToSize, checkTaskIsBT, checkTaskIsSeeder, + formatUsenetRepairFailure, getTaskName, getTaskUri, localeDateTimeFormat, @@ -177,6 +178,18 @@ export default { isBT() { return checkTaskIsBT(this.task); }, + usenetRepairFailureMessage() { + const failure = this.task?.usenetRepairFailure; + if (!failure) { + return ""; + } + return formatUsenetRepairFailure(failure, (key, params) => + this.$t(key, params), + ); + }, + taskErrorMessage() { + return this.usenetRepairFailureMessage || this.task?.errorMessage || ""; + }, displayDownloadSpeed() { return Number(this.task?.downloadSpeed || 0); }, diff --git a/src/renderer/router/index.ts b/src/renderer/router/index.ts index 8349cbd8..f30e1db0 100644 --- a/src/renderer/router/index.ts +++ b/src/renderer/router/index.ts @@ -64,6 +64,10 @@ export default createRouter({ path: "advanced", component: () => import("@/components/Preference/Advanced.vue"), }, + { + path: "usenet", + component: () => import("@/components/Preference/Usenet.vue"), + }, { path: "cloud-sinks", component: () => import("@/components/Preference/CloudSinks.vue"), diff --git a/src/renderer/store/batchQueue.ts b/src/renderer/store/batchQueue.ts index 22931951..9768d925 100644 --- a/src/renderer/store/batchQueue.ts +++ b/src/renderer/store/batchQueue.ts @@ -2,7 +2,7 @@ import { SELECTED_ALL_FILES } from "@shared/constants"; import type { MediaFormat } from "@shared/types/task"; import { isMediaUri } from "@shared/utils"; -type BatchItemKind = "torrent" | "uri" | "magnet" | "metalink"; +type BatchItemKind = "torrent" | "uri" | "magnet" | "metalink" | "nzb"; type BatchItemStatus = "queued" | "submitting" | "success" | "failed"; @@ -63,7 +63,7 @@ export interface BatchQueueItem { } const createFileBatchItem = ( - kind: Extract, + kind: Extract, resolveState: BatchItemResolveState, path: string, name?: string, @@ -88,12 +88,21 @@ const createTorrentBatchItem = (path: string, name?: string): BatchQueueItem => const createMetalinkBatchItem = (path: string, name?: string): BatchQueueItem => createFileBatchItem("metalink", "preview-disabled", path, name); +const createNzbBatchItem = (path: string, name?: string): BatchQueueItem => + createFileBatchItem("nzb", "preview-disabled", path, name); + const TORRENT_EXTS = ["torrent"]; const METALINK_EXTS = ["meta4", "metalink"]; +const NZB_EXTS = ["nzb"]; const extRe = (exts: string[]) => new RegExp(`\\.(${exts.join("|")})$`, "i"); const TORRENT_RE = extRe(TORRENT_EXTS); const METALINK_RE = extRe(METALINK_EXTS); -export const TASK_FILE_RE = extRe([...TORRENT_EXTS, ...METALINK_EXTS]); +const NZB_RE = extRe(NZB_EXTS); +export const TASK_FILE_RE = extRe([ + ...TORRENT_EXTS, + ...METALINK_EXTS, + ...NZB_EXTS, +]); export const batchItemForFilePath = ( path: string, @@ -105,6 +114,9 @@ export const batchItemForFilePath = ( if (METALINK_RE.test(path)) { return createMetalinkBatchItem(path, name); } + if (NZB_RE.test(path)) { + return createNzbBatchItem(path, name); + } return null; }; diff --git a/src/renderer/store/sync.ts b/src/renderer/store/sync.ts index a457f4cf..0d5116b8 100644 --- a/src/renderer/store/sync.ts +++ b/src/renderer/store/sync.ts @@ -1,9 +1,14 @@ import { getCategoriesForKey, syncCategories } from "@shared/syncCategories"; +import { + ANDROID_USENET_ARCHIVE_LIMITS, + DEFAULT_USENET_ARCHIVE_LIMITS, +} from "@shared/types/usenet"; import { changeKeysToKebabCase, getApiErrorMessage } from "@shared/utils"; import logger from "@shared/utils/logger"; import axios from "axios"; import { defineStore } from "pinia"; import api from "@/api"; +import is from "@/shims/platform"; import { useAuthStore } from "./auth"; import { usePreferenceStore } from "./preference"; @@ -19,6 +24,80 @@ let pushTimer: ReturnType | null = null; const pendingCategories = new Set(); const STATS_CATEGORY = "stats"; +function mergeUsenetProfiles( + localValue: unknown, + remoteValue: unknown, +): unknown[] { + const local = Array.isArray(localValue) ? localValue : []; + const remote = Array.isArray(remoteValue) ? remoteValue : []; + const byId = new Map>(); + for (const value of [...local, ...remote]) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + continue; + } + const profile = value as Record; + const id = typeof profile.id === "string" ? profile.id : ""; + if (!id) { + continue; + } + const previous = byId.get(id); + const currentAt = + typeof profile.updatedAt === "number" && + Number.isFinite(profile.updatedAt) + ? profile.updatedAt + : 0; + const previousAt = + typeof previous?.updatedAt === "number" && + Number.isFinite(previous.updatedAt) + ? previous.updatedAt + : 0; + if (!previous || currentAt > previousAt) { + byId.set(id, { ...profile }); + } + } + return [...byId.values()]; +} + +function mergeUsenetCategory( + localData: Record, + remoteData: Record, +): Record { + const defaults: Record = is.android() + ? { ...ANDROID_USENET_ARCHIVE_LIMITS } + : { ...DEFAULT_USENET_ARCHIVE_LIMITS }; + const limits = remoteData["usenet-archive-limits"]; + let adjusted = false; + if (limits && typeof limits === "object" && !Array.isArray(limits)) { + const clamped = { ...(limits as Record) }; + for (const [key, fallback] of Object.entries(defaults)) { + const value = Number(clamped[key]); + if ( + !Number.isFinite(value) || + !Number.isSafeInteger(value) || + value <= 0 + ) { + clamped[key] = fallback; + adjusted = true; + } else if (value > fallback * 4) { + clamped[key] = fallback * 4; + adjusted = true; + } else { + clamped[key] = value; + } + } + remoteData = { ...remoteData, "usenet-archive-limits": clamped }; + } + return { + ...remoteData, + "usenet-profiles": mergeUsenetProfiles( + localData["usenet-profiles"], + remoteData["usenet-profiles"], + ), + "usenet-limits-adjusted": + adjusted || remoteData["usenet-limits-adjusted"] === true, + }; +} + export const useSyncStore = defineStore("sync", { state: (): SyncState => ({ syncing: false, @@ -73,6 +152,10 @@ export const useSyncStore = defineStore("sync", { await api.mergeDownloadStats(data); return; } + if (categoryId === "usenet") { + const localData = await this.extractCategoryData(categoryId); + data = mergeUsenetCategory(localData, data); + } await usePreferenceStore().save(data, { skipSync: true }); }, @@ -144,6 +227,8 @@ export const useSyncStore = defineStore("sync", { if (remoteData) { if (categoryId === STATS_CATEGORY) { await this.applyCategoryData(categoryId, remoteData); + } else if (categoryId === "usenet") { + await this.applyCategoryData(categoryId, remoteData); } else { Object.assign(merged, remoteData); } @@ -212,6 +297,8 @@ export const useSyncStore = defineStore("sync", { if (!hasLocal && hasRemote && remoteData) { if (categoryId === STATS_CATEGORY) { await this.applyCategoryData(categoryId, remoteData); + } else if (categoryId === "usenet") { + await this.applyCategoryData(categoryId, remoteData); } else { Object.assign(toPull, remoteData); } @@ -230,6 +317,8 @@ export const useSyncStore = defineStore("sync", { if (localTimestamp === undefined && hasRemote && remoteData) { if (categoryId === STATS_CATEGORY) { await this.applyCategoryData(categoryId, remoteData); + } else if (categoryId === "usenet") { + await this.applyCategoryData(categoryId, remoteData); } else { Object.assign(toPull, remoteData); } @@ -246,6 +335,8 @@ export const useSyncStore = defineStore("sync", { } else if (remoteTimestamp > localTimestamp && remoteData) { if (categoryId === STATS_CATEGORY) { await this.applyCategoryData(categoryId, remoteData); + } else if (categoryId === "usenet") { + await this.applyCategoryData(categoryId, remoteData); } else { Object.assign(toPull, remoteData); } diff --git a/src/renderer/store/task.ts b/src/renderer/store/task.ts index 0bb06a29..052a926d 100644 --- a/src/renderer/store/task.ts +++ b/src/renderer/store/task.ts @@ -753,6 +753,14 @@ export const useTaskStore = defineStore("task", { appStore.updateAddTaskOptions({}); return results; }, + async addNzbs(data: { paths: string[]; options: Record }) { + const { paths, options } = data; + const results = await api.addNzbsByPaths({ paths, options }); + this.fetchList(); + const appStore = useAppStore(); + appStore.updateAddTaskOptions({}); + return results; + }, getTaskOption(gid: string) { return api.getOption({ gid }).catch((err: unknown) => { logger.warn("[Risuko] getTaskOption failed:", (err as Error).message); diff --git a/src/shared/configKeys.ts b/src/shared/configKeys.ts index cc2d49bd..8cbb06f5 100644 --- a/src/shared/configKeys.ts +++ b/src/shared/configKeys.ts @@ -52,6 +52,10 @@ const userKeys = [ "rss-auto-update", "rss-update-interval", "saved-credentials", + "usenet-profiles", + "usenet-archive-limits", + "usenet-cleanup-mode", + "usenet-limits-adjusted", "media-format", "youtube-format", "task-routing-rules", @@ -116,6 +120,7 @@ const systemKeys = [ "load-cookies", "lowest-speed-limit", "lowest-speed-limit-timeout", + "nzb-body-timeout", "max-concurrent-downloads", "max-download-limit", "max-overall-download-limit", diff --git a/src/shared/locales/ar/task.ts b/src/shared/locales/ar/task.ts index 3e4c561f..f656347d 100644 --- a/src/shared/locales/ar/task.ts +++ b/src/shared/locales/ar/task.ts @@ -132,4 +132,10 @@ export default { "mirror-placeholder": "رابط المرآة (نفس الملف، خادم آخر)", "mirror-hint": "أضف روابط مرآة تشير إلى نفس الملف للتنزيل من عدة مصادر في وقت واحد.", + "usenet-repair-insufficient": + "استرداد PAR2 غير كافٍ: يلزم {{neededBlocks}} كتلة، لكن المتاح هو {{availableBlocks}} فقط.", + "usenet-repair-partials-retained": + "تم الاحتفاظ بالملفات الجزئية. أضف مزودًا باكتمال أفضل، ثم أعد المحاولة.", + "usenet-repair-partials-unavailable": + "الملفات الجزئية غير متاحة. جرّب NZB مختلفًا أو أعد التنزيل باستخدام مزود باكتمال أفضل.", }; diff --git a/src/shared/locales/bg/task.ts b/src/shared/locales/bg/task.ts index 0c678f5b..c79299ea 100644 --- a/src/shared/locales/bg/task.ts +++ b/src/shared/locales/bg/task.ts @@ -136,4 +136,10 @@ export default { "mirror-placeholder": "URL на огледало (същият файл, друг сървър)", "mirror-hint": "Добавете URL адреси на огледала, които сочат към същия файл, за да изтегляте от няколко източника едновременно.", + "usenet-repair-insufficient": + "Възстановяването с PAR2 е недостатъчно: нужни са {{neededBlocks}} блока, но са налични само {{availableBlocks}}.", + "usenet-repair-partials-retained": + "Частичните файлове са запазени. Добавете доставчик с по-добра пълнота и опитайте отново.", + "usenet-repair-partials-unavailable": + "Частичните файлове не са налични. Опитайте друг NZB или изтеглете отново от доставчик с по-добра пълнота.", }; diff --git a/src/shared/locales/ca/task.ts b/src/shared/locales/ca/task.ts index b159c1d0..4266b103 100644 --- a/src/shared/locales/ca/task.ts +++ b/src/shared/locales/ca/task.ts @@ -141,4 +141,10 @@ export default { "mirror-placeholder": "URL de la rèplica (mateix fitxer, un altre servidor)", "mirror-hint": "Afegeix URL de rèpliques que apuntin al mateix fitxer per descarregar des de múltiples fonts en paral·lel.", + "usenet-repair-insufficient": + "La recuperació PAR2 és insuficient: calen {{neededBlocks}} blocs, però només n'hi ha {{availableBlocks}} disponibles.", + "usenet-repair-partials-retained": + "S'han conservat els fitxers parcials. Afegiu un proveïdor amb una completitud millor i torneu-ho a provar.", + "usenet-repair-partials-unavailable": + "Els fitxers parcials no estan disponibles. Proveu un NZB diferent o torneu a baixar-los amb un proveïdor amb una completitud millor.", }; diff --git a/src/shared/locales/de/task.ts b/src/shared/locales/de/task.ts index 59e58d78..9144c480 100644 --- a/src/shared/locales/de/task.ts +++ b/src/shared/locales/de/task.ts @@ -140,4 +140,10 @@ export default { "mirror-placeholder": "Spiegel-URL (gleiche Datei, anderer Server)", "mirror-hint": "Fügen Sie Spiegel-URLs hinzu, die auf dieselbe Datei verweisen, um von mehreren Quellen gleichzeitig herunterzuladen.", + "usenet-repair-insufficient": + "Die PAR2-Wiederherstellung ist unzureichend: {{neededBlocks}} Blöcke werden benötigt, aber nur {{availableBlocks}} sind verfügbar.", + "usenet-repair-partials-retained": + "Unvollständige Dateien wurden behalten. Fügen Sie einen Anbieter mit besserer Vollständigkeit hinzu und versuchen Sie es erneut.", + "usenet-repair-partials-unavailable": + "Teilweise Dateien sind nicht verfügbar. Versuchen Sie ein anderes NZB oder laden Sie erneut mit einem Anbieter mit besserer Vollständigkeit herunter.", }; diff --git a/src/shared/locales/el/task.ts b/src/shared/locales/el/task.ts index a0d0c6c7..2a68e714 100644 --- a/src/shared/locales/el/task.ts +++ b/src/shared/locales/el/task.ts @@ -141,4 +141,10 @@ export default { "mirror-placeholder": "URL καθρέφτη (ίδιο αρχείο, άλλος διακομιστής)", "mirror-hint": "Προσθέστε URL καθρεφτών που δείχνουν στο ίδιο αρχείο για λήψη από πολλές πηγές ταυτόχρονα.", + "usenet-repair-insufficient": + "Η ανάκτηση PAR2 είναι ανεπαρκής: απαιτούνται {{neededBlocks}} μπλοκ, αλλά είναι διαθέσιμα μόνο {{availableBlocks}}.", + "usenet-repair-partials-retained": + "Τα μερικά αρχεία διατηρήθηκαν. Προσθέστε έναν πάροχο με καλύτερη πληρότητα και δοκιμάστε ξανά.", + "usenet-repair-partials-unavailable": + "Τα μερικά αρχεία δεν είναι διαθέσιμα. Δοκιμάστε διαφορετικό NZB ή κατεβάστε ξανά με πάροχο καλύτερης πληρότητας.", }; diff --git a/src/shared/locales/en-US/preferences.ts b/src/shared/locales/en-US/preferences.ts index 4dd2ed8a..08e61d5c 100644 --- a/src/shared/locales/en-US/preferences.ts +++ b/src/shared/locales/en-US/preferences.ts @@ -2,6 +2,115 @@ export default { basic: "Basic", advanced: "Advanced", cloudSinks: "Cloud Sinks", + usenet: "Usenet", + "usenet-tips": "Manage NNTP providers and safe archive cleanup defaults.", + "usenet-profiles": "Provider Profiles", + "usenet-profiles-tips": + "Configure provider priority, security, authentication, and connection limits.", + "usenet-add-profile": "Add Profile", + "usenet-no-profiles": "No provider profiles configured.", + "usenet-no-profiles-tips": + "Add at least one provider before importing an NZB download.", + "usenet-edit-profile": "Edit Profile", + "usenet-profile-dialog-tips": + "Profile settings may sync between devices. Credentials remain local to this device.", + "usenet-profile-dialog-tips-vault": + "Profile settings may sync between devices. Credentials stay in this device's secure keychain.", + "usenet-profile-name": "Profile name", + "usenet-profile-name-placeholder": "Primary provider", + "usenet-profile-host": "NNTP host", + "usenet-profile-port": "Port", + "usenet-security": "Connection security", + "usenet-tls": "Implicit TLS", + "usenet-tls-short": "TLS", + "usenet-starttls": "STARTTLS", + "usenet-plain": "Plain NNTP", + "usenet-authentication": "Authentication", + "usenet-auth-credentials": "Username and password", + "usenet-auth-anonymous": "Anonymous", + "usenet-username": "Username", + "usenet-password": "Password", + "usenet-credentials-saved": "Credentials saved", + "usenet-credentials-unavailable": "Credential status unavailable", + "usenet-credentials-saved-tips": + "Credentials are already saved. Leave both fields empty to keep them unchanged.", + "usenet-plain-warning-title": "Unencrypted connection", + "usenet-plain-opt-in": + "I understand and want to allow plain NNTP for this profile.", + "usenet-plain-warning": + "Plain NNTP does not encrypt credentials or downloaded data. Use it only when your provider explicitly requires it.", + "usenet-enabled": "Enabled", + "usenet-enabled-tips": + "Disabled profiles are kept but are not used for downloads.", + "usenet-disabled": "Disabled", + "usenet-priority": "Priority", + "usenet-priority-tips": + "Lower values are tried first. Equal priorities share the load.", + "usenet-priority-value": "Priority {{value}}", + "usenet-max-connections": "Maximum connections", + "usenet-max-connections-tips": + "The connection cap for this provider profile.", + "usenet-connections-value": "{{value}} connections", + "usenet-test": "Test", + "usenet-edit": "Edit profile", + "usenet-remove": "Remove", + "usenet-remove-title": "Remove provider profile?", + "usenet-remove-confirm": + "{{name}} will no longer be available for downloads. Its saved credentials will also be removed from this device.", + "usenet-profile-saved": "Provider profile saved.", + "usenet-profile-removed": "{{name}} was removed.", + "usenet-test-success": "Connected to {{name}}.", + "usenet-test-failed": "Connection failed: {{message}}", + "usenet-validation-name": "Enter a profile name.", + "usenet-validation-host": "Enter an NNTP host.", + "usenet-validation-port": "Port must be between 1 and 65535.", + "usenet-validation-plain": "Confirm the plain NNTP warning before saving.", + "usenet-validation-priority": "Priority must be zero or greater.", + "usenet-validation-connections": "Connections must be between 1 and 128.", + "usenet-validation-credentials": + "Enter both a username and password, or choose anonymous authentication.", + "usenet-validation-credential-status": + "Credential status is unavailable. Try again before changing this profile.", + "usenet-cleanup": "Successful cleanup", + "usenet-cleanup-tips": + "Cleanup runs only after complete assembly and successful extraction.", + "usenet-cleanup-action": "After verified success", + "usenet-cleanup-keep": "Keep all inputs", + "usenet-cleanup-keep-tips": "Retains PAR2 files and every archive volume.", + "usenet-cleanup-par2": "Delete PAR2 files", + "usenet-cleanup-par2-tips": + "Removes parity files but keeps downloaded archive volumes.", + "usenet-cleanup-all": "Delete PAR2 and archive volumes", + "usenet-cleanup-all-tips": + "Removes parity files and archive volumes after extraction succeeds.", + "usenet-archive-safety": "Archive safety", + "usenet-limits-defaults": + "Task-wide limits protect storage and processing during verification and extraction.", + "usenet-desktop-limits": "Desktop limits", + "usenet-android-limits": "Android limits", + "usenet-limits-summary": + "{{entries}} entries · {{total}} total expansion · {{perEntry}} per entry", + "usenet-limits-secondary-summary": + "{{ratio}}:1 ratio · {{depth}} nested levels · {{reserve}} free-space reserve · {{hours}} active hours", + "usenet-customize-limits": "Customize limits", + "usenet-hide-limits": "Hide limits", + "usenet-limits-adjusted": "Synced limits were adjusted", + "usenet-limits-adjusted-tips": + "One or more values exceeded this platform's hard ceiling and were reduced.", + "usenet-max-entries": "Maximum entries", + "usenet-max-expanded": "Total expanded size", + "usenet-max-entry": "Single-entry size", + "usenet-max-depth": "Nesting depth", + "usenet-max-ratio": "Compression ratio", + "usenet-free-space-reserve": "Free-space reserve", + "usenet-max-active-time": "Active processing time", + "usenet-unit-entries": "entries", + "usenet-unit-levels": "levels", + "usenet-unit-hours": "hours", + "usenet-limits-hard-cap": + "Values cannot exceed four times the platform defaults. Per-task overrides remain separately confirmed.", + "usenet-restore-limits": "Restore platform defaults", + "usenet-limits-restored": "Archive limits restored to platform defaults.", save: "Save & Apply", "save-success-message": "Preferences saved successfully", "save-fail-message": "Preferences failed to save", @@ -65,6 +174,7 @@ export default { "http-reliability-tips": "Tune connect timeout and stalled-transfer detection for HTTP/HTTPS/FTP downloads.", "connect-timeout": "Connect timeout", + "nzb-body-timeout": "NZB body timeout", "lowest-speed-limit": "Lowest speed limit", "lowest-speed-limit-timeout": "Stall timeout", "lowest-speed-limit-help": diff --git a/src/shared/locales/en-US/sync.ts b/src/shared/locales/en-US/sync.ts index ac41fdc4..a9602d6c 100644 --- a/src/shared/locales/en-US/sync.ts +++ b/src/shared/locales/en-US/sync.ts @@ -57,6 +57,7 @@ export default { "category-ports": "Ports", "category-ftp": "FTP / SFTP", "category-g2-gnutella": "G2 / Gnutella", + "category-usenet": "Usenet", "category-misc": "Other", login: "Login", logout: "Logout", diff --git a/src/shared/locales/en-US/task.ts b/src/shared/locales/en-US/task.ts index 87634251..4a15e78e 100644 --- a/src/shared/locales/en-US/task.ts +++ b/src/shared/locales/en-US/task.ts @@ -271,6 +271,12 @@ export default { "download-pause-message": "Paused downloading {{taskName}}", "download-stop-message": "Stopped downloading {{taskName}}", "download-error-message": "Error occurred when downloading {{taskName}}", + "usenet-repair-insufficient": + "PAR2 recovery is insufficient: {{neededBlocks}} blocks are needed, but only {{availableBlocks}} are available.", + "usenet-repair-partials-retained": + "Partial files were kept. Add a provider with better completion, then retry.", + "usenet-repair-partials-unavailable": + "Partial files are unavailable. Try a different NZB or download again with a provider that has better completion.", "media-tool-required": 'Task "{{taskName}}": yt-dlp is required for media-site downloads. Please install yt-dlp and ensure it is available in PATH.', "media-auth-required": diff --git a/src/shared/locales/es/task.ts b/src/shared/locales/es/task.ts index 97a404d3..71f77031 100644 --- a/src/shared/locales/es/task.ts +++ b/src/shared/locales/es/task.ts @@ -139,4 +139,10 @@ export default { "mirror-placeholder": "URL de la réplica (mismo archivo, otro servidor)", "mirror-hint": "Añade URL de réplicas que apunten al mismo archivo para descargar desde múltiples fuentes en paralelo.", + "usenet-repair-insufficient": + "La recuperación PAR2 es insuficiente: se necesitan {{neededBlocks}} bloques, pero solo hay {{availableBlocks}} disponibles.", + "usenet-repair-partials-retained": + "Se conservaron los archivos parciales. Añade un proveedor con mejor completitud y vuelve a intentarlo.", + "usenet-repair-partials-unavailable": + "Los archivos parciales no están disponibles. Prueba con otro NZB o descarga de nuevo con un proveedor con mejor completitud.", }; diff --git a/src/shared/locales/fa/task.ts b/src/shared/locales/fa/task.ts index 05d10d0b..4568ad7a 100644 --- a/src/shared/locales/fa/task.ts +++ b/src/shared/locales/fa/task.ts @@ -133,4 +133,10 @@ export default { "mirror-placeholder": "نشانی آینه (همان فایل، سرور دیگر)", "mirror-hint": "نشانی‌های آینه را که به همان فایل اشاره می‌کنند اضافه کنید تا از چند منبع به‌طور همزمان دانلود کنید.", + "usenet-repair-insufficient": + "بازیابی PAR2 کافی نیست: {{neededBlocks}} بلوک لازم است، اما فقط {{availableBlocks}} بلوک در دسترس است.", + "usenet-repair-partials-retained": + "فایل‌های ناقص حفظ شدند. ارائه‌دهنده‌ای با تکمیل بهتر اضافه کنید و دوباره تلاش کنید.", + "usenet-repair-partials-unavailable": + "فایل‌های ناقص در دسترس نیستند. NZB دیگری را امتحان کنید یا با ارائه‌دهنده‌ای با تکمیل بهتر دوباره دانلود کنید.", }; diff --git a/src/shared/locales/fr/task.ts b/src/shared/locales/fr/task.ts index ab5483a9..14df4e2a 100644 --- a/src/shared/locales/fr/task.ts +++ b/src/shared/locales/fr/task.ts @@ -135,4 +135,10 @@ export default { "mirror-placeholder": "URL du miroir (même fichier, autre serveur)", "mirror-hint": "Ajoutez des URL de miroirs pointant vers le même fichier pour télécharger depuis plusieurs sources en parallèle.", + "usenet-repair-insufficient": + "La récupération PAR2 est insuffisante : {{neededBlocks}} blocs sont nécessaires, mais seuls {{availableBlocks}} sont disponibles.", + "usenet-repair-partials-retained": + "Les fichiers partiels ont été conservés. Ajoutez un fournisseur avec une meilleure complétude, puis réessayez.", + "usenet-repair-partials-unavailable": + "Les fichiers partiels ne sont pas disponibles. Essayez un autre NZB ou téléchargez à nouveau avec un fournisseur offrant une meilleure complétude.", }; diff --git a/src/shared/locales/hu/task.ts b/src/shared/locales/hu/task.ts index 23a11e5f..1ce027f2 100644 --- a/src/shared/locales/hu/task.ts +++ b/src/shared/locales/hu/task.ts @@ -131,4 +131,10 @@ export default { "mirror-placeholder": "Tükör URL (ugyanaz a fájl, másik kiszolgáló)", "mirror-hint": "Adjon hozzá ugyanarra a fájlra mutató tükör URL-eket, hogy egyszerre több forrásból tölthessen le.", + "usenet-repair-insufficient": + "A PAR2-helyreállítás nem elegendő: {{neededBlocks}} blokk szükséges, de csak {{availableBlocks}} érhető el.", + "usenet-repair-partials-retained": + "A részleges fájlokat megtartottuk. Adjon hozzá jobb teljességű szolgáltatót, majd próbálja újra.", + "usenet-repair-partials-unavailable": + "A részleges fájlok nem érhetők el. Próbáljon másik NZB-t, vagy töltse le újra jobb teljességű szolgáltatón keresztül.", }; diff --git a/src/shared/locales/id/task.ts b/src/shared/locales/id/task.ts index 26e71cb2..121c8924 100644 --- a/src/shared/locales/id/task.ts +++ b/src/shared/locales/id/task.ts @@ -135,4 +135,10 @@ export default { "mirror-placeholder": "URL mirror (berkas sama, server lain)", "mirror-hint": "Tambahkan URL mirror yang menunjuk ke berkas yang sama untuk mengunduh dari beberapa sumber secara paralel.", + "usenet-repair-insufficient": + "Pemulihan PAR2 tidak mencukupi: diperlukan {{neededBlocks}} blok, tetapi hanya {{availableBlocks}} yang tersedia.", + "usenet-repair-partials-retained": + "Berkas parsial dipertahankan. Tambahkan penyedia dengan kelengkapan yang lebih baik, lalu coba lagi.", + "usenet-repair-partials-unavailable": + "Berkas parsial tidak tersedia. Coba NZB lain atau unduh kembali dengan penyedia yang memiliki kelengkapan lebih baik.", }; diff --git a/src/shared/locales/it/task.ts b/src/shared/locales/it/task.ts index 6ae259aa..5e247b6e 100644 --- a/src/shared/locales/it/task.ts +++ b/src/shared/locales/it/task.ts @@ -139,4 +139,10 @@ export default { "mirror-placeholder": "URL del mirror (stesso file, altro server)", "mirror-hint": "Aggiungi URL di mirror che puntano allo stesso file per scaricare da più fonti in parallelo.", + "usenet-repair-insufficient": + "Il recupero PAR2 è insufficiente: sono necessari {{neededBlocks}} blocchi, ma ne sono disponibili solo {{availableBlocks}}.", + "usenet-repair-partials-retained": + "I file parziali sono stati conservati. Aggiungi un provider con una completezza migliore, quindi riprova.", + "usenet-repair-partials-unavailable": + "I file parziali non sono disponibili. Prova un NZB diverso o scarica di nuovo con un provider con una completezza migliore.", }; diff --git a/src/shared/locales/ja/task.ts b/src/shared/locales/ja/task.ts index 91ce4b6d..adc694b6 100644 --- a/src/shared/locales/ja/task.ts +++ b/src/shared/locales/ja/task.ts @@ -136,4 +136,10 @@ export default { "mirror-placeholder": "ミラーURL(同じファイル、別のサーバー)", "mirror-hint": "同じファイルを指すミラーURLを追加すると、複数のソースから並列でダウンロードできます。", + "usenet-repair-insufficient": + "PAR2 の復旧に必要なブロックが不足しています。必要数は {{neededBlocks}} 個ですが、利用できるのは {{availableBlocks}} 個しかありません。", + "usenet-repair-partials-retained": + "部分ファイルは保持されています。より完全性の高いプロバイダーを追加して、もう一度お試しください。", + "usenet-repair-partials-unavailable": + "部分ファイルを利用できません。別の NZB を試すか、より完全性の高いプロバイダーで再度ダウンロードしてください。", }; diff --git a/src/shared/locales/ko/task.ts b/src/shared/locales/ko/task.ts index 7612dea1..c42d701d 100644 --- a/src/shared/locales/ko/task.ts +++ b/src/shared/locales/ko/task.ts @@ -133,4 +133,10 @@ export default { "mirror-placeholder": "미러 URL (같은 파일, 다른 서버)", "mirror-hint": "같은 파일을 가리키는 미러 URL을 추가하면 여러 소스에서 동시에 다운로드할 수 있습니다.", + "usenet-repair-insufficient": + "PAR2 복구 데이터가 부족합니다. {{neededBlocks}}개 블록이 필요하지만 {{availableBlocks}}개만 사용할 수 있습니다.", + "usenet-repair-partials-retained": + "부분 파일을 보존했습니다. 완성도가 더 높은 제공자를 추가한 후 다시 시도하세요.", + "usenet-repair-partials-unavailable": + "부분 파일을 사용할 수 없습니다. 다른 NZB를 시도하거나 완성도가 더 높은 제공자를 통해 다시 다운로드하세요.", }; diff --git a/src/shared/locales/nb/task.ts b/src/shared/locales/nb/task.ts index 4134c344..acd9cac7 100644 --- a/src/shared/locales/nb/task.ts +++ b/src/shared/locales/nb/task.ts @@ -138,4 +138,10 @@ export default { "mirror-placeholder": "Speil-URL (samme fil, annen tjener)", "mirror-hint": "Legg til speil-URL-er som peker til samme fil for å laste ned fra flere kilder samtidig.", + "usenet-repair-insufficient": + "PAR2-gjenopprettingen er utilstrekkelig: {{neededBlocks}} blokker trengs, men bare {{availableBlocks}} er tilgjengelige.", + "usenet-repair-partials-retained": + "Delvise filer ble beholdt. Legg til en leverandør med bedre fullstendighet, og prøv igjen.", + "usenet-repair-partials-unavailable": + "Delvise filer er ikke tilgjengelige. Prøv en annen NZB, eller last ned på nytt med en leverandør med bedre fullstendighet.", }; diff --git a/src/shared/locales/nl/task.ts b/src/shared/locales/nl/task.ts index 42fef048..198e2334 100644 --- a/src/shared/locales/nl/task.ts +++ b/src/shared/locales/nl/task.ts @@ -140,4 +140,10 @@ export default { "mirror-placeholder": "Spiegel-URL (zelfde bestand, andere server)", "mirror-hint": "Voeg spiegel-URL's toe die naar hetzelfde bestand verwijzen om van meerdere bronnen tegelijk te downloaden.", + "usenet-repair-insufficient": + "PAR2-herstelgegevens zijn onvoldoende: er zijn {{neededBlocks}} blokken nodig, maar slechts {{availableBlocks}} zijn beschikbaar.", + "usenet-repair-partials-retained": + "Gedeeltelijke bestanden zijn bewaard. Voeg een provider met een betere volledigheid toe en probeer het opnieuw.", + "usenet-repair-partials-unavailable": + "Gedeeltelijke bestanden zijn niet beschikbaar. Probeer een andere NZB of download opnieuw met een provider met een betere volledigheid.", }; diff --git a/src/shared/locales/pl/task.ts b/src/shared/locales/pl/task.ts index 3216200a..3bea1848 100644 --- a/src/shared/locales/pl/task.ts +++ b/src/shared/locales/pl/task.ts @@ -139,4 +139,10 @@ export default { "mirror-placeholder": "URL serwera lustrzanego (ten sam plik, inny serwer)", "mirror-hint": "Dodaj adresy URL serwerów lustrzanych wskazujące ten sam plik, aby pobierać z wielu źródeł równolegle.", + "usenet-repair-insufficient": + "Odzyskiwanie PAR2 jest niewystarczające: potrzeba {{neededBlocks}} bloków, ale dostępnych jest tylko {{availableBlocks}}.", + "usenet-repair-partials-retained": + "Pliki częściowe zostały zachowane. Dodaj dostawcę z lepszą kompletnością i spróbuj ponownie.", + "usenet-repair-partials-unavailable": + "Pliki częściowe są niedostępne. Spróbuj innego NZB lub pobierz ponownie od dostawcy z lepszą kompletnością.", }; diff --git a/src/shared/locales/pt-BR/task.ts b/src/shared/locales/pt-BR/task.ts index f506afab..27a4827e 100644 --- a/src/shared/locales/pt-BR/task.ts +++ b/src/shared/locales/pt-BR/task.ts @@ -139,4 +139,10 @@ export default { "mirror-placeholder": "URL do espelho (mesmo arquivo, outro servidor)", "mirror-hint": "Adicione URLs de espelho que apontem para o mesmo arquivo para baixar de várias fontes em paralelo.", + "usenet-repair-insufficient": + "A recuperação PAR2 é insuficiente: são necessários {{neededBlocks}} blocos, mas apenas {{availableBlocks}} estão disponíveis.", + "usenet-repair-partials-retained": + "Os arquivos parciais foram mantidos. Adicione um provedor com melhor completude e tente novamente.", + "usenet-repair-partials-unavailable": + "Os arquivos parciais não estão disponíveis. Tente outro NZB ou baixe novamente com um provedor com melhor completude.", }; diff --git a/src/shared/locales/ro/task.ts b/src/shared/locales/ro/task.ts index 769bbb06..915ae87c 100644 --- a/src/shared/locales/ro/task.ts +++ b/src/shared/locales/ro/task.ts @@ -140,4 +140,10 @@ export default { "mirror-placeholder": "URL oglindă (același fișier, alt server)", "mirror-hint": "Adaugă URL-uri de oglindă care indică același fișier pentru a descărca din mai multe surse în paralel.", + "usenet-repair-insufficient": + "Recuperarea PAR2 este insuficientă: sunt necesare {{neededBlocks}} blocuri, dar sunt disponibile doar {{availableBlocks}}.", + "usenet-repair-partials-retained": + "Fișierele parțiale au fost păstrate. Adăugați un furnizor cu o completitudine mai bună și încercați din nou.", + "usenet-repair-partials-unavailable": + "Fișierele parțiale nu sunt disponibile. Încercați un NZB diferit sau descărcați din nou de la un furnizor cu o completitudine mai bună.", }; diff --git a/src/shared/locales/ru/task.ts b/src/shared/locales/ru/task.ts index 6070704d..8071356d 100644 --- a/src/shared/locales/ru/task.ts +++ b/src/shared/locales/ru/task.ts @@ -137,4 +137,10 @@ export default { "mirror-placeholder": "URL зеркала (тот же файл, другой сервер)", "mirror-hint": "Добавьте URL зеркал, указывающих на тот же файл, чтобы загружать из нескольких источников одновременно.", + "usenet-repair-insufficient": + "Восстановление PAR2 недостаточно: требуется {{neededBlocks}} блоков, но доступно только {{availableBlocks}}.", + "usenet-repair-partials-retained": + "Частичные файлы сохранены. Добавьте провайдера с лучшей полнотой и повторите попытку.", + "usenet-repair-partials-unavailable": + "Частичные файлы недоступны. Попробуйте другой NZB или загрузите снова через провайдера с лучшей полнотой.", }; diff --git a/src/shared/locales/th/task.ts b/src/shared/locales/th/task.ts index 34c89d9c..434c83d9 100644 --- a/src/shared/locales/th/task.ts +++ b/src/shared/locales/th/task.ts @@ -128,4 +128,10 @@ export default { "mirror-remove": "ลบมิเรอร์", "mirror-placeholder": "URL มิเรอร์ (ไฟล์เดียวกัน เซิร์ฟเวอร์อื่น)", "mirror-hint": "เพิ่ม URL มิเรอร์ที่ชี้ไปยังไฟล์เดียวกันเพื่อดาวน์โหลดจากหลายแหล่งพร้อมกัน", + "usenet-repair-insufficient": + "ข้อมูลกู้คืน PAR2 ไม่เพียงพอ: ต้องใช้ {{neededBlocks}} บล็อก แต่มีเพียง {{availableBlocks}} บล็อกเท่านั้น", + "usenet-repair-partials-retained": + "เก็บไฟล์บางส่วนไว้แล้ว เพิ่มผู้ให้บริการที่มีความสมบูรณ์ดีกว่า แล้วลองอีกครั้ง", + "usenet-repair-partials-unavailable": + "ไฟล์บางส่วนไม่พร้อมใช้งาน ลองใช้ NZB อื่นหรือดาวน์โหลดอีกครั้งผ่านผู้ให้บริการที่มีความสมบูรณ์ดีกว่า", }; diff --git a/src/shared/locales/tr/task.ts b/src/shared/locales/tr/task.ts index 48ac81c6..1a46b415 100644 --- a/src/shared/locales/tr/task.ts +++ b/src/shared/locales/tr/task.ts @@ -137,4 +137,10 @@ export default { "mirror-placeholder": "Yansı URL'si (aynı dosya, başka sunucu)", "mirror-hint": "Birden fazla kaynaktan paralel indirmek için aynı dosyaya işaret eden yansı URL'leri ekleyin.", + "usenet-repair-insufficient": + "PAR2 kurtarma verisi yetersiz: {{neededBlocks}} blok gerekiyor, ancak yalnızca {{availableBlocks}} mevcut.", + "usenet-repair-partials-retained": + "Kısmi dosyalar korundu. Tamamlanma oranı daha yüksek bir sağlayıcı ekleyip tekrar deneyin.", + "usenet-repair-partials-unavailable": + "Kısmi dosyalar kullanılamıyor. Farklı bir NZB deneyin veya tamamlanma oranı daha yüksek bir sağlayıcıyla yeniden indirin.", }; diff --git a/src/shared/locales/uk/task.ts b/src/shared/locales/uk/task.ts index b8e7fe56..f7555116 100644 --- a/src/shared/locales/uk/task.ts +++ b/src/shared/locales/uk/task.ts @@ -137,4 +137,10 @@ export default { "mirror-placeholder": "URL дзеркала (той самий файл, інший сервер)", "mirror-hint": "Додайте URL дзеркал, що вказують на той самий файл, щоб завантажувати з кількох джерел одночасно.", + "usenet-repair-insufficient": + "Даних для відновлення PAR2 недостатньо: потрібно {{neededBlocks}} блоків, але доступно лише {{availableBlocks}}.", + "usenet-repair-partials-retained": + "Часткові файли збережено. Додайте провайдера з кращою повнотою та повторіть спробу.", + "usenet-repair-partials-unavailable": + "Часткові файли недоступні. Спробуйте інший NZB або завантажте знову через провайдера з кращою повнотою.", }; diff --git a/src/shared/locales/vi/task.ts b/src/shared/locales/vi/task.ts index 2eec709d..c86b2075 100644 --- a/src/shared/locales/vi/task.ts +++ b/src/shared/locales/vi/task.ts @@ -136,4 +136,10 @@ export default { "mirror-placeholder": "URL máy chủ gương (cùng tệp, máy chủ khác)", "mirror-hint": "Thêm các URL máy chủ gương trỏ đến cùng một tệp để tải xuống từ nhiều nguồn song song.", + "usenet-repair-insufficient": + "Dữ liệu khôi phục PAR2 không đủ: cần {{neededBlocks}} khối nhưng chỉ có {{availableBlocks}} khối.", + "usenet-repair-partials-retained": + "Các tệp một phần đã được giữ lại. Thêm nhà cung cấp có độ hoàn thiện tốt hơn rồi thử lại.", + "usenet-repair-partials-unavailable": + "Các tệp một phần không khả dụng. Hãy thử NZB khác hoặc tải lại với nhà cung cấp có độ hoàn thiện tốt hơn.", }; diff --git a/src/shared/locales/zh-CN/preferences.ts b/src/shared/locales/zh-CN/preferences.ts index e6c775ed..e7dd819b 100644 --- a/src/shared/locales/zh-CN/preferences.ts +++ b/src/shared/locales/zh-CN/preferences.ts @@ -63,6 +63,7 @@ export default { "http-reliability": "HTTP / 网络可靠性", "http-reliability-tips": "调整 HTTP/HTTPS/FTP 下载的连接超时与停滞检测。", "connect-timeout": "连接超时", + "nzb-body-timeout": "NZB 响应体超时", "lowest-speed-limit": "最低速度阈值", "lowest-speed-limit-timeout": "停滞超时", "lowest-speed-limit-help": diff --git a/src/shared/locales/zh-CN/task.ts b/src/shared/locales/zh-CN/task.ts index 9c59c940..55a72d84 100644 --- a/src/shared/locales/zh-CN/task.ts +++ b/src/shared/locales/zh-CN/task.ts @@ -301,4 +301,10 @@ export default { "clipboard-download-prompt": "下载此链接?", "clipboard-download-accept": "下载", "clipboard-download-ignore": "忽略", + "usenet-repair-insufficient": + "PAR2 恢复数据不足:需要 {{neededBlocks}} 个数据块,但只有 {{availableBlocks}} 个可用。", + "usenet-repair-partials-retained": + "已保留部分文件。请添加完整度更高的提供商,然后重试。", + "usenet-repair-partials-unavailable": + "部分文件不可用。请尝试其他 NZB,或使用完整度更高的提供商重新下载。", }; diff --git a/src/shared/locales/zh-TW/preferences.ts b/src/shared/locales/zh-TW/preferences.ts index c6020986..fa74de66 100644 --- a/src/shared/locales/zh-TW/preferences.ts +++ b/src/shared/locales/zh-TW/preferences.ts @@ -52,6 +52,7 @@ export default { "http-reliability": "HTTP / 網路可靠性", "http-reliability-tips": "調整 HTTP/HTTPS/FTP 下載的連線逾時與停滯偵測。", "connect-timeout": "連線逾時", + "nzb-body-timeout": "NZB 回應內容逾時", "lowest-speed-limit": "最低速度閾值", "lowest-speed-limit-timeout": "停滯逾時", "lowest-speed-limit-help": diff --git a/src/shared/locales/zh-TW/sync.ts b/src/shared/locales/zh-TW/sync.ts index 2299c5d8..236e93b6 100644 --- a/src/shared/locales/zh-TW/sync.ts +++ b/src/shared/locales/zh-TW/sync.ts @@ -52,6 +52,7 @@ export default { "category-ports": "連接埠", "category-ftp": "FTP / SFTP", "category-g2-gnutella": "G2 / Gnutella", + "category-usenet": "Usenet", "category-misc": "其他", login: "登入", logout: "登出", diff --git a/src/shared/locales/zh-TW/task.ts b/src/shared/locales/zh-TW/task.ts index dbb6ffc9..90e02294 100644 --- a/src/shared/locales/zh-TW/task.ts +++ b/src/shared/locales/zh-TW/task.ts @@ -241,6 +241,12 @@ export default { "download-pause-message": "暫停下載 {{taskName}}", "download-stop-message": "{{taskName}} 下載中止", "download-error-message": "{{taskName}} 下載錯誤", + "usenet-repair-insufficient": + "PAR2 修復資料不足:需要 {{neededBlocks}} 個區塊,但目前只有 {{availableBlocks}} 個。", + "usenet-repair-partials-retained": + "已保留部分檔案。新增完整度較高的提供者後重試。", + "usenet-repair-partials-unavailable": + "部分檔案已不可用。請嘗試其他 NZB,或使用完整度較高的提供者重新下載。", "media-tool-required": '任務"{{taskName}}":媒體站點下載需要 yt-dlp。請先安裝 yt-dlp,並確認已加入 PATH。', "media-auth-required": diff --git a/src/shared/syncCategories.ts b/src/shared/syncCategories.ts index 15014f2a..29a28f48 100644 --- a/src/shared/syncCategories.ts +++ b/src/shared/syncCategories.ts @@ -212,6 +212,16 @@ const namedCategories: SyncCategory[] = [ "sftp-user", ], }, + { + id: "usenet", + keys: [ + "usenet-profiles", + "usenet-archive-limits", + "usenet-cleanup-mode", + "usenet-limits-adjusted", + "nzb-body-timeout", + ], + }, { id: "g2-gnutella", keys: [ diff --git a/src/shared/types/config.ts b/src/shared/types/config.ts index 463da2c3..ad27b861 100644 --- a/src/shared/types/config.ts +++ b/src/shared/types/config.ts @@ -1,4 +1,9 @@ import type { SavedCredential } from "./credential"; +import type { + UsenetArchiveLimits, + UsenetCleanupMode, + UsenetProviderProfile, +} from "./usenet"; export const FONT_FAMILY_OPTIONS = [ "system", @@ -80,6 +85,10 @@ export interface AppConfig { historyDirectories?: string[]; favoriteDirectories?: string[]; savedCredentials?: SavedCredential[]; + usenetProfiles?: UsenetProviderProfile[]; + usenetArchiveLimits?: UsenetArchiveLimits; + usenetCleanupMode?: UsenetCleanupMode; + usenetLimitsAdjusted?: boolean; protocols?: { magnet?: boolean | string; thunder?: boolean | string; diff --git a/src/shared/types/task.ts b/src/shared/types/task.ts index 6685e2a4..fbbf27c2 100644 --- a/src/shared/types/task.ts +++ b/src/shared/types/task.ts @@ -1,3 +1,5 @@ +import type { UsenetTaskOptions } from "./usenet"; + interface FileUri { uri: string; status: string; @@ -21,6 +23,12 @@ interface BitTorrentInfo { metaVersion?: "v1" | "v2" | "hybrid"; } +export interface UsenetRepairFailure { + neededBlocks: number; + availableBlocks: number; + partialsRetained: boolean; +} + export interface DownloadTask { gid: string; status: string; @@ -50,6 +58,18 @@ export interface DownloadTask { numPieces?: string; options?: Record; tag?: string; + usenet?: { + options?: UsenetTaskOptions; + files?: Array<{ + name: string; + subject: string; + groups: string[]; + segments: Array<{ number: number; bytes: number; messageId: string }>; + }>; + }; + usenetStage?: string; + usenetWarning?: string; + usenetRepairFailure?: UsenetRepairFailure; chunkProgress?: { completedLength: string; totalLength: string }[]; } diff --git a/src/shared/types/usenet.ts b/src/shared/types/usenet.ts new file mode 100644 index 00000000..8c8fce78 --- /dev/null +++ b/src/shared/types/usenet.ts @@ -0,0 +1,57 @@ +export type UsenetSecurityMode = "implicit-tls" | "starttls" | "plain"; + +export type UsenetCleanupMode = + | "keep-all" + | "delete-par2" + | "delete-par2-and-archives"; + +export interface UsenetProviderProfile { + id: string; + name: string; + host: string; + port: number; + securityMode: UsenetSecurityMode; + enabled: boolean; + priority: number; + maxConnections: number; + allowPlain: boolean; + deletedAt?: number; + updatedAt?: number; +} + +export interface UsenetArchiveLimits { + maxEntries: number; + maxExpandedBytes: number; + maxEntryBytes: number; + maxNestingDepth: number; + maxCompressionRatio: number; + freeSpaceReserveBytes: number; + maxActiveSeconds: number; +} + +export const DEFAULT_USENET_ARCHIVE_LIMITS: UsenetArchiveLimits = { + maxEntries: 500_000, + maxExpandedBytes: 2 * 1024 ** 4, + maxEntryBytes: 512 * 1024 ** 3, + maxNestingDepth: 16, + maxCompressionRatio: 1000, + freeSpaceReserveBytes: 10 * 1024 ** 3, + maxActiveSeconds: 6 * 60 * 60, +}; + +export const ANDROID_USENET_ARCHIVE_LIMITS: UsenetArchiveLimits = { + maxEntries: 100_000, + maxExpandedBytes: 256 * 1024 ** 3, + maxEntryBytes: 64 * 1024 ** 3, + maxNestingDepth: 16, + maxCompressionRatio: 1000, + freeSpaceReserveBytes: 2 * 1024 ** 3, + maxActiveSeconds: 2 * 60 * 60, +}; + +export interface UsenetTaskOptions { + profileId?: string; + archiveLimits?: Partial; + archiveLimitOverrideConfirmed?: boolean; + cleanupMode?: UsenetCleanupMode; +} diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 8f7b89e8..28a29e5f 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -19,6 +19,7 @@ import { } from "lodash"; export { bytesToSize } from "./format"; +export { formatUsenetRepairFailure } from "./usenet"; // human message from an axios-style error, with a 429 special-case // and a caller-supplied fallback; shared by the sync + share stores diff --git a/src/shared/utils/usenet.ts b/src/shared/utils/usenet.ts new file mode 100644 index 00000000..16eb31b2 --- /dev/null +++ b/src/shared/utils/usenet.ts @@ -0,0 +1,26 @@ +import type { UsenetRepairFailure } from "@shared/types/task"; + +export type UsenetTranslator = ( + key: string, + params?: Record, +) => string; + +export const formatUsenetRepairFailure = ( + failure: UsenetRepairFailure | undefined, + translate: UsenetTranslator, +): string => { + if (!failure) { + return ""; + } + + const summary = translate("task.usenet-repair-insufficient", { + neededBlocks: failure.neededBlocks, + availableBlocks: failure.availableBlocks, + }); + const nextStep = translate( + failure.partialsRetained + ? "task.usenet-repair-partials-retained" + : "task.usenet-repair-partials-unavailable", + ); + return `${summary} ${nextStep}`; +};