From 1e48335f85372dd697916e47f02ffc4f7d47e169 Mon Sep 17 00:00:00 2001 From: NimBold Date: Tue, 15 Sep 2026 04:25:44 +0330 Subject: [PATCH] chore(deps): refresh dependencies and engine locks - update React, Vite, i18next, Cargo, reqwest, and rustls dependency floors - refresh verified Windows and Linux FFmpeg source archives - harden cleanup ownership, identity fencing, DNS classification, and cancellation safety --- engine-sources.lock.json | 12 +- package-lock.json | 229 +++--- package.json | 16 +- src-tauri/Cargo.lock | 284 +++---- src-tauri/Cargo.toml | 4 +- src-tauri/src/lib.rs | 1506 ++++++++++++++++++++++++++++++-------- 6 files changed, 1466 insertions(+), 585 deletions(-) diff --git a/engine-sources.lock.json b/engine-sources.lock.json index 0d9bb99..1f56850 100644 --- a/engine-sources.lock.json +++ b/engine-sources.lock.json @@ -13,9 +13,9 @@ "sha256": "15e5300b0ba3c3695a7621d90160a746ec9e710228cee639afa9d580f6e3cd11" }, "ffmpeg": { - "version": "9.0.1-26-g5c8e7e2433", - "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-06-13-06/ffmpeg-n9.0.1-26-g5c8e7e2433-win64-gpl-9.0.zip", - "sha256": "dd232ccf8661f837a1faa5f534a1a0bdbdb25c42afe79391e8345154df78f791" + "version": "9.0.1-29-gad500d59cb", + "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-14-13-17/ffmpeg-n9.0.1-29-gad500d59cb-win64-gpl-9.0.zip", + "sha256": "755186bad114362356359d8e4e6e6f1e2bac3ed4df12b94588f004b411859428" }, "aria2c": { "version": "1.37.0-firelink-native-dns-v1", @@ -45,9 +45,9 @@ "sha256": "394f07f4da2bebe6ce6f1e7ce0fa16429b29b08c35e3fac3fe25972676dff4b2" }, "ffmpeg": { - "version": "9.0.1-26-g5c8e7e2433", - "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-06-13-06/ffmpeg-n9.0.1-26-g5c8e7e2433-linux64-gpl-9.0.tar.xz", - "sha256": "e60c4187c792cc35d2558adbae5582c470713f5afed7212999200340f1394f8d" + "version": "9.0.1-29-gad500d59cb", + "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-14-13-17/ffmpeg-n9.0.1-29-gad500d59cb-linux64-gpl-9.0.tar.xz", + "sha256": "f78852b0c4286d245a6bc989704ea03f8f7dcd8b66cd1ad55d190131d4f21bb9" }, "aria2c": { "version": "1.37.0-firelink-native-dns-v1", diff --git a/package-lock.json b/package-lock.json index 10da293..7381d7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,22 +25,22 @@ "@tauri-apps/plugin-notification": "^2.4.0", "@tauri-apps/plugin-opener": "^2.5.5", "i18next": "^26.4.2", - "lucide-react": "^1.42.0", - "react": "^19.2.8", - "react-dom": "^19.2.8", - "react-i18next": "^17.0.13", + "lucide-react": "^1.46.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-i18next": "^17.0.14", "zustand": "^5.0.15" }, "devDependencies": { "@tauri-apps/cli": "^2.11.4", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.7", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", - "autoprefixer": "^10.5.5", + "autoprefixer": "^10.6.0", "postcss": "^8.5.28", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", - "vite": "^8.2.2", + "vite": "^8.3.0", "vitest": "^5.0.0" }, "engines": { @@ -162,18 +162,34 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", - "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", - "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ "arm64" ], @@ -187,9 +203,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ "arm64" ], @@ -203,9 +219,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", - "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ "x64" ], @@ -219,9 +235,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", - "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ "x64" ], @@ -235,9 +251,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", - "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "cpu": [ "arm" ], @@ -251,9 +267,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", - "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "cpu": [ "arm64" ], @@ -270,9 +286,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", - "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "cpu": [ "arm64" ], @@ -289,9 +305,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", - "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "cpu": [ "ppc64" ], @@ -308,9 +324,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", - "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ "s390x" ], @@ -327,9 +343,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", - "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "cpu": [ "x64" ], @@ -346,9 +362,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", - "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "cpu": [ "x64" ], @@ -365,9 +381,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", - "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "cpu": [ "arm64" ], @@ -381,9 +397,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", - "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "cpu": [ "arm64" ], @@ -397,9 +413,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", - "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "cpu": [ "x64" ], @@ -1069,9 +1085,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1079,13 +1095,13 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@typescript/typescript-aix-ppc64": { @@ -1497,9 +1513,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.5", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.5.tgz", - "integrity": "sha512-uiRYvQYe/nNSzBJ7OUnd2/TZVsAdob3blml44teEpee9Cc1f4rGZFewO+JT3Wo8mgFOSzNqes4FHZn/Qz8WOuw==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.0.tgz", + "integrity": "sha512-A26d6qs9kqGgkmImIXMYvXTzqb4Qv7AVgpY1NXzr9Y659J8qHHnLOO/zE8ewIGFMprOolAoRAQYDgNryXIJKBw==", "dev": true, "funding": [ { @@ -2043,9 +2059,9 @@ } }, "node_modules/lucide-react": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.42.0.tgz", - "integrity": "sha512-b3jprplnoLS8n5etw1z8xODe3hF/yjKATrTitsrKrnjUhCef5BdDct6Ppv3zVvzFwmtfWgLO6XNM3C9fAD93ug==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.46.0.tgz", + "integrity": "sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2156,30 +2172,30 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.3.0" } }, "node_modules/react-i18next": { - "version": "17.0.13", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz", - "integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==", + "version": "17.0.14", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.14.tgz", + "integrity": "sha512-ZpMBfJL3BiXPuYHj5QMY1GwvKJNhE1vCxOJQ8BoMBdHP5XONTfi/Qss1nuRlnFdIl72PKo2jPmw+fxuLjRFgaQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.7", @@ -2204,12 +2220,12 @@ } }, "node_modules/rolldown": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", - "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.144.0", + "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2219,26 +2235,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.4", - "@rolldown/binding-darwin-arm64": "1.2.4", - "@rolldown/binding-darwin-x64": "1.2.4", - "@rolldown/binding-freebsd-x64": "1.2.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", - "@rolldown/binding-linux-arm64-gnu": "1.2.4", - "@rolldown/binding-linux-arm64-musl": "1.2.4", - "@rolldown/binding-linux-ppc64-gnu": "1.2.4", - "@rolldown/binding-linux-s390x-gnu": "1.2.4", - "@rolldown/binding-linux-x64-gnu": "1.2.4", - "@rolldown/binding-linux-x64-musl": "1.2.4", - "@rolldown/binding-openharmony-arm64": "1.2.4", - "@rolldown/binding-win32-arm64-msvc": "1.2.4", - "@rolldown/binding-win32-x64-msvc": "1.2.4" + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/siginfo": { @@ -2402,15 +2419,15 @@ } }, "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "bin": { @@ -2427,7 +2444,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index ec88f92..713dd8e 100644 --- a/package.json +++ b/package.json @@ -61,22 +61,22 @@ "@tauri-apps/plugin-notification": "^2.4.0", "@tauri-apps/plugin-opener": "^2.5.5", "i18next": "^26.4.2", - "lucide-react": "^1.42.0", - "react": "^19.2.8", - "react-dom": "^19.2.8", - "react-i18next": "^17.0.13", + "lucide-react": "^1.46.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-i18next": "^17.0.14", "zustand": "^5.0.15" }, "devDependencies": { "@tauri-apps/cli": "^2.11.4", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.7", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", - "autoprefixer": "^10.5.5", + "autoprefixer": "^10.6.0", "postcss": "^8.5.28", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", - "vite": "^8.2.2", + "vite": "^8.3.0", "vitest": "^5.0.0" } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 99c1d9e..de56c96 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -366,9 +366,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -491,7 +491,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cairo-sys-rs", "glib", "libc", @@ -563,9 +563,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" dependencies = [ "find-msvc-tools", "shlex", @@ -731,7 +731,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation", "core-graphics-types", "foreign-types", @@ -744,7 +744,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation", "libc", ] @@ -781,9 +781,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -888,12 +888,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -912,15 +912,15 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -936,13 +936,13 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -1103,7 +1103,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -1233,7 +1233,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.5+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "vswhom", "winreg", ] @@ -1246,9 +1246,9 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" [[package]] name = "encoding_rs" -version = "0.8.40" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a7a45518d2863d18aa47f4a0cf9faec2aa4304cc09df5e41299f276b3ad135e" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" dependencies = [ "cfg-if", "core_detect", @@ -1805,7 +1805,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "futures-channel", "futures-core", "futures-executor", @@ -2073,9 +2073,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] @@ -2395,9 +2395,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" dependencies = [ "defmt", "jiff-core", @@ -2412,18 +2412,19 @@ dependencies = [ [[package]] name = "jiff-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ "defmt", + "log", ] [[package]] name = "jiff-static" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" dependencies = [ "jiff-core", "proc-macro2", @@ -2574,7 +2575,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "serde", "unicode-segmentation", ] @@ -2639,9 +2640,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" dependencies = [ "libc", ] @@ -2809,24 +2810,23 @@ dependencies = [ [[package]] name = "multiversion" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" dependencies = [ "multiversion-macros", - "target-features", ] [[package]] name = "multiversion-macros" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "target-features", + "rustversion", + "syn 3.0.5", ] [[package]] @@ -2841,7 +2841,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3031,7 +3031,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "objc2", "objc2-core-foundation", @@ -3045,7 +3045,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -3066,7 +3066,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "dispatch2", "libc", @@ -3079,7 +3079,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2", "objc2-core-foundation", @@ -3112,7 +3112,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3139,7 +3139,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -3152,7 +3152,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "dispatch2", "libc", @@ -3166,7 +3166,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", ] @@ -3188,7 +3188,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -3200,7 +3200,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "objc2", "objc2-cloud-kit", @@ -3231,7 +3231,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "objc2", "objc2-app-kit", @@ -3247,9 +3247,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.4.3" +version = "5.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" dependencies = [ "dunce", "is-wsl", @@ -3477,7 +3477,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", @@ -3569,7 +3569,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] @@ -3714,7 +3714,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -3779,11 +3779,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-core", "futures-util", @@ -3870,7 +3870,7 @@ version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3910,7 +3910,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -3919,9 +3919,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.44" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -4102,7 +4102,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -4125,7 +4125,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cssparser", "derive_more", "log", @@ -4268,11 +4268,11 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bs58", "chrono", "hex", @@ -4289,14 +4289,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -4447,9 +4447,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" @@ -4670,7 +4670,7 @@ version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "core-foundation", "core-graphics", @@ -4715,12 +4715,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "target-features" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" - [[package]] name = "target-lexicon" version = "0.12.16" @@ -4931,7 +4925,7 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.20", - "toml 1.1.5+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "url", ] @@ -5117,7 +5111,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.20", - "toml 1.1.5+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "url", "urlpattern", "uuid", @@ -5132,7 +5126,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.5+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", ] [[package]] @@ -5284,18 +5278,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" @@ -5388,9 +5373,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.5+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap 2.14.2", "serde_core", @@ -5454,9 +5439,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", @@ -5501,7 +5486,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -5519,7 +5504,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "http", "http-body", @@ -5576,9 +5561,9 @@ dependencies = [ [[package]] name = "trash" -version = "5.2.7" +version = "5.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08676401d031c4a43b7022d90e1748ef4624fabb68ac2abf0cb5812f8c02d78e" +checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317" dependencies = [ "chrono", "libc", @@ -5589,7 +5574,7 @@ dependencies = [ "percent-encoding", "scopeguard", "urlencoding", - "windows 0.56.0", + "windows 0.62.2", ] [[package]] @@ -5800,9 +5785,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5969,7 +5954,7 @@ version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "rustix", "wayland-backend", "wayland-scanner", @@ -5981,7 +5966,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-scanner", @@ -5993,7 +5978,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6105,8 +6090,8 @@ dependencies = [ "webview2-com-sys", "windows 0.61.3", "windows-core 0.61.2", - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", ] [[package]] @@ -6183,16 +6168,6 @@ dependencies = [ "windows-version", ] -[[package]] -name = "windows" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" -dependencies = [ - "windows-core 0.56.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.3" @@ -6236,26 +6211,14 @@ dependencies = [ "windows-core 0.62.2", ] -[[package]] -name = "windows-core" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" -dependencies = [ - "windows-implement 0.56.0", - "windows-interface 0.56.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -6267,8 +6230,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -6296,17 +6259,6 @@ dependencies = [ "windows-threading 0.2.1", ] -[[package]] -name = "windows-implement" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -6318,17 +6270,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "windows-interface" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -6396,15 +6337,6 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -6951,18 +6883,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f73183f..fae7b90 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,8 +31,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] } regex = "1.10" -reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json", "stream", "socks"] } -rustls = { version = "0.23.44", default-features = false, features = ["ring", "std", "tls12"] } +reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "json", "stream", "socks"] } +rustls = { version = "0.23.45", default-features = false, features = ["ring", "std", "tls12"] } uuid = { version = "1", features = ["v4"] } ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] } tauri-plugin-notification = "2.4.0" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 88013d3..98d06f2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1495,60 +1495,145 @@ fn emit_media_progress( } } -async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) { - cleanup_media_artifacts(out_path, true).await; +async fn cleanup_media_processing_artifacts( + app_handle: &tauri::AppHandle, + expected_owner: &str, + out_path: &std::path::Path, +) { + let _ = cleanup_media_artifacts(app_handle, Some(expected_owner), out_path, true).await; } -async fn remove_file_best_effort_with_retry(path: &std::path::Path) { +async fn remove_file_best_effort_with_retry( + path: &std::path::Path, + app_handle: &tauri::AppHandle, + expected_owner: Option<&str>, +) -> Result<(), String> { + validate_cleanup_target_owner(app_handle, path, expected_owner)?; + let Some(snapshot) = capture_regular_file_snapshot(path, app_handle).await? else { + return Ok(()); + }; + for attempt in 0..=5 { - match tokio::fs::remove_file(path).await { - Ok(()) => return, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + if !validate_regular_file_snapshot(path, app_handle, &snapshot)? { + return Err(format!( + "download cleanup target '{}' disappeared during cleanup; preserving ownership", + path.display() + )); + } + match run_trash_operation(|| std::fs::remove_file(path)) { + Ok(()) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(format!( + "download cleanup target '{}' disappeared during cleanup; preserving ownership", + path.display() + )); + } Err(error) if attempt == 5 => { - log::warn!( - "failed to remove media artifact '{}' after retries: {}", - path.display(), - error - ); + return Err(format!( + "failed to remove media artifact '{}' after retries: {error}", + path.display() + )); } Err(_) => { tokio::time::sleep(std::time::Duration::from_millis(200)).await; } } } + Ok(()) } -async fn cleanup_media_artifacts(out_path: &std::path::Path, remove_primary: bool) { +async fn collect_media_processing_artifact_paths( + out_path: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result, String> { + if !is_safe_path(out_path, app_handle) { + return Err("download media artifact path is outside an allowed location".to_string()); + } let Some(parent) = out_path.parent() else { - return; + return Ok(Vec::new()); + }; + if !is_safe_path(parent, app_handle) { + return Err("download media artifact directory is outside an allowed location".to_string()); }; let Some(base_name) = out_path.file_name().and_then(|name| name.to_str()) else { - return; + return Ok(Vec::new()); }; let base_stem = out_path .file_stem() .and_then(|name| name.to_str()) .unwrap_or(base_name); - if remove_primary { - remove_file_best_effort_with_retry(out_path).await; - } - - let Ok(mut entries) = tokio::fs::read_dir(parent).await else { - return; + let mut entries = match tokio::fs::read_dir(parent).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("could not inspect media artifacts: {error}")), }; - while let Ok(Some(entry)) = entries.next_entry().await { + let mut artifacts = Vec::new(); + while let Some(entry) = entries + .next_entry() + .await + .map_err(|error| format!("could not inspect media artifacts: {error}"))? + { let path = entry.path(); - if path == out_path { + if crate::platform::paths_equal(&path, out_path) { continue; } let Some(name) = path.file_name().and_then(|name| name.to_str()) else { continue; }; if is_media_artifact_name(name, base_name, base_stem) { - remove_file_best_effort_with_retry(&path).await; + artifacts.push(path); } } + Ok(artifacts) +} + +async fn cleanup_media_artifacts( + app_handle: &tauri::AppHandle, + expected_owner: Option<&str>, + out_path: &std::path::Path, + remove_primary: bool, +) -> Result<(), String> { + let mut targets = collect_media_processing_artifact_paths(out_path, app_handle).await?; + if remove_primary { + targets.push(out_path.to_path_buf()); + } + let _target_guards = lock_download_targets(&mut targets).await; + cleanup_media_artifacts_locked(app_handle, expected_owner, out_path, remove_primary).await +} + +async fn cleanup_media_artifacts_locked( + app_handle: &tauri::AppHandle, + expected_owner: Option<&str>, + out_path: &std::path::Path, + remove_primary: bool, +) -> Result<(), String> { + let artifacts = validate_media_artifact_targets(app_handle, expected_owner, out_path).await?; + if remove_primary { + validate_cleanup_target_owner(app_handle, out_path, expected_owner)?; + let _ = capture_regular_file_snapshot(out_path, app_handle).await?; + } + + if remove_primary { + remove_file_best_effort_with_retry(out_path, app_handle, expected_owner).await?; + } + for path in artifacts { + remove_file_best_effort_with_retry(&path, app_handle, expected_owner).await?; + } + Ok(()) +} + +async fn validate_media_artifact_targets( + app_handle: &tauri::AppHandle, + expected_owner: Option<&str>, + out_path: &std::path::Path, +) -> Result, String> { + let artifacts = collect_media_processing_artifact_paths(out_path, app_handle).await?; + for artifact in &artifacts { + validate_cleanup_target_owner(app_handle, artifact, expected_owner)?; + let _ = capture_regular_file_snapshot(artifact, app_handle).await?; + } + Ok(artifacts) } fn is_media_artifact_name(name: &str, base_name: &str, base_stem: &str) -> bool { @@ -2403,6 +2488,8 @@ async fn fetch_metadata( fn reqwest_error_code(error: &reqwest::Error) -> &'static str { if error.is_timeout() { "timeout" + } else if error.is_dns() { + "dns" } else if error.is_connect() { "connect" } else if error.is_request() { @@ -2570,6 +2657,51 @@ async fn download_target_lock(path: &std::path::Path) -> std::sync::Arc, +) -> Vec> { + lock_download_targets_excluding(paths, &[]).await +} + +async fn lock_download_targets_excluding( + paths: &mut Vec, + excluded: &[std::path::PathBuf], +) -> Vec> { + // Media processing artifacts share the output directory namespace with + // the primary file. Lock each target's immediate parent as well as the + // exact target so a concurrent enqueue cannot claim a newly-discovered + // artifact between directory enumeration and validation/unlink. + let initial_paths = paths.clone(); + for path in initial_paths { + if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) { + paths.push(parent.to_path_buf()); + } + } + paths.retain(|path| { + !excluded + .iter() + .any(|excluded| crate::platform::paths_equal(path, excluded)) + }); + paths.sort_by_key(|path| crate::platform::path_identity(path)); + paths.dedup_by(|left, right| crate::platform::paths_equal(left, right)); + let mut guards = Vec::with_capacity(paths.len()); + for path in paths.iter() { + guards.push(download_target_lock(path).await.lock_owned().await); + } + guards +} + +fn download_sidecar_paths(primary: &std::path::Path) -> Vec { + [".aria2", ".part", ".ytdl"] + .into_iter() + .map(|suffix| { + let mut candidate = primary.as_os_str().to_os_string(); + candidate.push(suffix); + candidate.into() + }) + .collect() +} + #[allow(clippy::too_many_arguments)] // Hash every user-controlled yt-dlp input explicitly. fn media_metadata_cache_key( url: &str, @@ -5042,7 +5174,7 @@ pub(crate) async fn start_media_download_internal( _ = cancel_rx.changed() => { terminate_shell_process_tree(child); if processing_started { - cleanup_media_processing_artifacts(&out_path).await; + cleanup_media_processing_artifacts(&app_handle, id, &out_path).await; } return Err(crate::queue::MEDIA_RUN_CANCELLED.to_string()); } @@ -5220,7 +5352,7 @@ pub(crate) async fn start_media_download_internal( let transient = is_transient_network_error(&failure_reason); let strikes_left = strike < max_retries; if should_cleanup_media_artifacts_after_failure(&failure_reason, strike, max_retries) { - cleanup_media_artifacts(&out_path, false).await; + let _ = cleanup_media_artifacts(&app_handle, Some(id), &out_path, false).await; } if should_retry_without_browser_cookies( effective_cookie_source.as_deref(), @@ -6550,22 +6682,30 @@ async fn remove_download_inner( // record is removed, so another download cannot claim a path between // deleting its payload/sidecars and releasing Firelink ownership. let mut cleanup_target_guards = Vec::new(); + let mut cleanup_target_paths = Vec::new(); if should_delete_assets { let mut cleanup_targets = owned_paths.clone(); + for path in &owned_paths { + cleanup_targets.extend(download_sidecar_paths(path)); + cleanup_targets.extend( + collect_media_processing_artifact_paths(path, &app_handle).await?, + ); + } if let Some(primary) = primary_path .as_ref() .filter(|primary| primary_path_needs_container_cleanup(primary, &owned_paths)) { cleanup_targets.push(primary.clone()); + cleanup_targets.extend(download_sidecar_paths(primary)); + cleanup_targets.extend( + collect_media_processing_artifact_paths(primary, &app_handle).await?, + ); } if let Some(replacement) = unowned_replacement_target.as_ref() { cleanup_targets.push(replacement.target.clone()); } - cleanup_targets.sort_by_key(|path| crate::platform::path_identity(path)); - cleanup_targets.dedup_by(|left, right| crate::platform::paths_equal(left, right)); - for target in cleanup_targets { - cleanup_target_guards.push(download_target_lock(&target).await.lock_owned().await); - } + cleanup_target_guards = lock_download_targets(&mut cleanup_targets).await; + cleanup_target_paths = cleanup_targets; } if durable_removal_job { @@ -6591,9 +6731,9 @@ async fn remove_download_inner( if should_delete_assets { for path in &owned_paths { if permanent_asset_removal { - remove_download_assets_permanently(path, &app_handle).await?; + remove_download_assets_permanently_locked(path, &app_handle).await?; } else { - remove_download_assets(path, &app_handle).await?; + remove_download_assets_locked(path, &app_handle).await?; } } if let Some(primary) = primary_path @@ -6601,9 +6741,15 @@ async fn remove_download_inner( .filter(|primary| primary_path_needs_container_cleanup(primary, &owned_paths)) { if permanent_asset_removal { - remove_download_container_assets_permanently(primary, &app_handle).await?; + remove_download_container_assets_permanently_locked( + primary, + &app_handle, + &cleanup_target_paths, + ) + .await?; } else { - remove_download_container_assets(primary, &app_handle).await?; + remove_download_container_assets_locked(primary, &app_handle, &cleanup_target_paths) + .await?; } } if let Some(replacement) = unowned_replacement_target.as_ref() { @@ -6686,10 +6832,9 @@ struct UnownedReplacementTarget { fingerprint: String, } -async fn validate_unowned_replacement_target( +fn validate_unowned_replacement_target_shape( app_handle: &tauri::AppHandle, target: &std::path::Path, - expected_fingerprint: &str, metadata: &std::fs::Metadata, allowed_legacy_owner_id: &str, ) -> Result<(), String> { @@ -6723,6 +6868,22 @@ async fn validate_unowned_replacement_target( )); } } + Ok(()) +} + +async fn validate_unowned_replacement_target( + app_handle: &tauri::AppHandle, + target: &std::path::Path, + expected_fingerprint: &str, + metadata: &std::fs::Metadata, + allowed_legacy_owner_id: &str, +) -> Result<(), String> { + validate_unowned_replacement_target_shape( + app_handle, + target, + metadata, + allowed_legacy_owner_id, + )?; if !target_fingerprint_matches_async(target, metadata, expected_fingerprint).await? { return Err( "The replacement target changed after duplicate resolution. Reopen the conflict and choose Replace again." @@ -6732,6 +6893,28 @@ async fn validate_unowned_replacement_target( Ok(()) } +fn validate_unowned_replacement_target_sync( + app_handle: &tauri::AppHandle, + target: &std::path::Path, + expected_fingerprint: &str, + metadata: &std::fs::Metadata, + allowed_legacy_owner_id: &str, +) -> Result<(), String> { + validate_unowned_replacement_target_shape( + app_handle, + target, + metadata, + allowed_legacy_owner_id, + )?; + if !target_fingerprint_matches(target, metadata, expected_fingerprint)? { + return Err( + "The replacement target changed after duplicate resolution. Reopen the conflict and choose Replace again." + .to_string(), + ); + } + Ok(()) +} + async fn inspect_unowned_replacement_target( app_handle: &tauri::AppHandle, persisted: &crate::ipc::DownloadItem, @@ -6801,6 +6984,336 @@ async fn inspect_unowned_replacement_target( })) } +#[derive(Debug, Clone)] +enum TrashTargetSnapshot { + File { + identity: String, + len: u64, + modified: String, + changed: String, + }, + Directory { + identity: String, + modified: String, + changed: String, + }, +} + +#[derive(Debug)] +enum TrashAttemptError { + Validation(String), + Operation { kind: &'static str }, +} + +fn trash_failure_message(label: &str) -> String { + format!( + "Could not move the {label} to Trash after retries; permanent deletion was not attempted" + ) +} + +fn trash_error_kind(error: &trash::Error) -> &'static str { + match error { + trash::Error::Unknown { .. } => "unknown", + trash::Error::Os { .. } => "os", + #[cfg(all( + unix, + not(target_os = "macos"), + not(target_os = "ios"), + not(target_os = "android") + ))] + trash::Error::FileSystem { .. } => "filesystem", + trash::Error::TargetedRoot => "targeted_root", + trash::Error::CouldNotAccess { .. } => "could_not_access", + trash::Error::CanonicalizePath { .. } => "canonicalize", + trash::Error::ConvertOsString { .. } => "path_conversion", + trash::Error::RestoreCollision { .. } => "restore_collision", + trash::Error::RestoreTwins { .. } => "restore_twins", + } +} + +#[cfg(test)] +static TEST_TRASH_FAILURES: OnceLock>> = + OnceLock::new(); + +#[cfg(test)] +fn set_test_trash_failures(path: &std::path::Path, count: usize) { + let failures = TEST_TRASH_FAILURES.get_or_init(|| std::sync::Mutex::new(HashMap::new())); + let mut failures = failures.lock().unwrap(); + if count == 0 { + failures.remove(path); + } else { + failures.insert(path.to_path_buf(), count); + } +} + +fn delete_to_trash(path: &std::path::Path) -> Result<(), trash::Error> { + #[cfg(test)] + { + let failures = TEST_TRASH_FAILURES.get_or_init(|| std::sync::Mutex::new(HashMap::new())); + let mut failures = failures.lock().unwrap(); + if let Some(remaining) = failures.get_mut(path) { + if *remaining > 0 { + *remaining -= 1; + return Err(trash::Error::Unknown { + description: "injected Trash failure".to_string(), + }); + } + } + } + + trash::delete(path) +} + +/// Run a synchronous Trash operation without yielding while its caller's +/// lifecycle and target guards remain held. Dropping a `spawn_blocking` +/// JoinHandle on cancellation would release those guards while the +/// cross-volume copy/delete continued. The production Tauri runtime is +/// multi-threaded; current-thread tests and the already blocking durable +/// worker use the direct path. +fn run_trash_operation(operation: impl FnOnce() -> T) -> T { + let use_blocking_runtime = tokio::runtime::Handle::try_current().is_ok_and(|handle| { + matches!( + handle.runtime_flavor(), + tokio::runtime::RuntimeFlavor::MultiThread + ) + }); + if use_blocking_runtime { + tokio::task::block_in_place(operation) + } else { + operation() + } +} + +fn trash_target_identity( + path: &std::path::Path, + metadata: &std::fs::Metadata, +) -> Result { + #[cfg(windows)] + { + let _ = metadata; + let identity = crate::platform::file_identity(path) + .ok_or_else(|| "Could not establish download asset identity".to_string())?; + Ok(format!("windows:{identity}")) + } + #[cfg(not(windows))] + { + Ok(target_identity(path, metadata)) + } +} + +fn trash_target_snapshot_from_metadata( + path: &std::path::Path, + metadata: &std::fs::Metadata, +) -> Result { + if metadata_is_link_or_reparse(metadata) || path_has_symlink_component(path) { + return Err("Download asset is a symbolic link or reparse point".to_string()); + } + let identity = trash_target_identity(path, metadata)?; + if metadata.is_file() { + return Ok(TrashTargetSnapshot::File { + identity, + len: metadata.len(), + modified: target_modified(metadata), + changed: target_changed(metadata), + }); + } + if metadata.is_dir() { + return Ok(TrashTargetSnapshot::Directory { + identity, + modified: target_modified(metadata), + changed: target_changed(metadata), + }); + } + Err("Download asset is not a regular file or directory".to_string()) +} + +async fn capture_trash_target_snapshot( + path: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result, String> { + if !is_safe_path(path, app_handle) { + return Err("Download asset path is outside an allowed download location".to_string()); + } + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err("Could not inspect download asset before Trash cleanup".to_string()), + }; + trash_target_snapshot_from_metadata(path, &metadata) + .map(Some) + .map_err(|_| "Could not verify download asset before Trash cleanup".to_string()) +} + +fn validate_trash_target_snapshot( + path: &std::path::Path, + app_handle: &tauri::AppHandle, + expected: &TrashTargetSnapshot, +) -> Result { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(_) => { + return Err(TrashAttemptError::Validation( + "Could not inspect download asset before moving it to Trash".to_string(), + )) + } + }; + if !is_safe_path(path, app_handle) { + return Err(TrashAttemptError::Validation( + "Download asset path is outside an allowed download location".to_string(), + )); + } + if metadata_is_link_or_reparse(&metadata) || path_has_symlink_component(path) { + return Err(TrashAttemptError::Validation( + "Download asset changed to a symbolic link or reparse point".to_string(), + )); + } + let identity = trash_target_identity(path, &metadata).map_err(TrashAttemptError::Validation)?; + + match expected { + TrashTargetSnapshot::File { + identity: expected_identity, + len, + modified, + changed, + } => { + if !metadata.is_file() + || identity != *expected_identity + || metadata.len() != *len + || target_modified(&metadata) != *modified + || target_changed(&metadata) != *changed + { + return Err(TrashAttemptError::Validation( + "Download asset changed during Trash cleanup; preserving it".to_string(), + )); + } + } + TrashTargetSnapshot::Directory { + identity: expected_identity, + modified, + changed, + } => { + if !metadata.is_dir() + || identity != *expected_identity + || target_modified(&metadata) != *modified + || target_changed(&metadata) != *changed + { + return Err(TrashAttemptError::Validation( + "Download directory changed during Trash cleanup; preserving it".to_string(), + )); + } + if directory_has_non_metadata_entries_sync(path).map_err(|_| { + TrashAttemptError::Validation( + "Could not inspect download directory before moving it to Trash".to_string(), + ) + })? { + return Err(TrashAttemptError::Validation( + "refusing to move a non-empty download directory to Trash without explicit ownership" + .to_string(), + )); + } + } + } + Ok(true) +} + +fn validate_cleanup_target_owner( + app_handle: &tauri::AppHandle, + path: &std::path::Path, + expected_owner: Option<&str>, +) -> Result<(), String> { + if let Some(owner) = crate::download_ownership::owner_for_path(app_handle, path)? { + if expected_owner != Some(owner.as_str()) { + return Err(format!( + "Cannot remove download asset '{}' because it is owned by Firelink download {owner}", + path.display() + )); + } + } + Ok(()) +} + +async fn capture_regular_file_snapshot( + path: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result, String> { + let snapshot = capture_trash_target_snapshot(path, app_handle).await?; + match snapshot { + Some(TrashTargetSnapshot::File { .. }) => Ok(snapshot), + Some(TrashTargetSnapshot::Directory { .. }) => Err(format!( + "Download cleanup target '{}' is a directory", + path.display() + )), + None => Ok(None), + } +} + +fn validate_regular_file_snapshot( + path: &std::path::Path, + app_handle: &tauri::AppHandle, + expected: &TrashTargetSnapshot, +) -> Result { + match expected { + TrashTargetSnapshot::File { .. } => { + validate_trash_target_snapshot(path, app_handle, expected) + .map_err(|error| match error { + TrashAttemptError::Validation(message) => message, + TrashAttemptError::Operation { .. } => { + "Download cleanup target validation failed".to_string() + } + }) + } + TrashTargetSnapshot::Directory { .. } => { + Err("Download cleanup target is a directory".to_string()) + } + } +} + +fn trash_delete_with_snapshot( + path: &std::path::Path, + app_handle: &tauri::AppHandle, + expected: &TrashTargetSnapshot, +) -> Result<(), TrashAttemptError> { + run_trash_operation(|| match validate_trash_target_snapshot(path, app_handle, expected) { + Ok(false) => Ok(()), + Ok(true) => delete_to_trash(path).map_err(|error| TrashAttemptError::Operation { + kind: trash_error_kind(&error), + }), + Err(error) => Err(error), + }) +} + +fn trash_delete_replacement_target( + target: &std::path::Path, + expected_fingerprint: &str, + allowed_legacy_owner_id: &str, + app_handle: &tauri::AppHandle, +) -> Result<(), TrashAttemptError> { + run_trash_operation(|| { + let metadata = match std::fs::symlink_metadata(target) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => { + return Err(TrashAttemptError::Validation( + "Could not inspect replacement target before moving it to Trash".to_string(), + )) + } + }; + if let Err(error) = validate_unowned_replacement_target_sync( + app_handle, + target, + expected_fingerprint, + &metadata, + allowed_legacy_owner_id, + ) { + return Err(TrashAttemptError::Validation(error)); + } + delete_to_trash(target).map_err(|error| TrashAttemptError::Operation { + kind: trash_error_kind(&error), + }) + }) +} + async fn remove_unowned_replacement_target( target: &std::path::Path, expected_fingerprint: &str, @@ -6819,16 +7332,15 @@ async fn remove_unowned_replacement_target( )); } }; - validate_unowned_replacement_target( - app_handle, - target, - expected_fingerprint, - &metadata, - allowed_legacy_owner_id, - ) - .await?; - if permanent { + validate_unowned_replacement_target( + app_handle, + target, + expected_fingerprint, + &metadata, + allowed_legacy_owner_id, + ) + .await?; match tokio::fs::remove_file(target).await { Ok(()) => return Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), @@ -6841,47 +7353,29 @@ async fn remove_unowned_replacement_target( Err(_) => {} } } else { - let remove_result = match trash::delete(target) { - Ok(()) => Ok(()), - Err(error) => { - log::warn!( - "failed to move authorized replacement target to Trash, attempting hard delete: {}", - error - ); - let current_metadata = match std::fs::symlink_metadata(target) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(format!( - "Could not inspect replacement target '{}': {error}", - target.display() - )); - } - }; - validate_unowned_replacement_target( - app_handle, - target, - expected_fingerprint, - ¤t_metadata, - allowed_legacy_owner_id, - ) - .await?; - match std::fs::remove_file(target) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => Err(error.to_string()), - } - } - }; - match remove_result { + match trash_delete_replacement_target( + target, + expected_fingerprint, + allowed_legacy_owner_id, + app_handle, + ) { Ok(()) => return Ok(()), - Err(error) if attempt == 5 => { - return Err(format!( - "Could not remove replacement target '{}' after retries: {error}", - target.display() - )); + Err(TrashAttemptError::Validation(error)) => return Err(error), + Err(TrashAttemptError::Operation { kind }) if attempt == 5 => { + log::warn!( + "replacement Trash operation failed [attempt={} error_kind={}]", + attempt + 1, + kind + ); + return Err(trash_failure_message("replacement target")); + } + Err(TrashAttemptError::Operation { kind }) => { + log::warn!( + "replacement Trash operation failed [attempt={} error_kind={}]", + attempt + 1, + kind + ); } - Err(_) => {} } } @@ -7036,70 +7530,98 @@ pub(crate) async fn remove_download_assets( primary: &std::path::Path, app_handle: &tauri::AppHandle, ) -> Result<(), String> { - if !is_safe_path(primary, app_handle) { - return Err("Download asset path is outside an allowed download location".to_string()); - } - - if primary.exists() { - let mut retries = 5; - loop { - let res = if primary.is_dir() { - if directory_has_non_metadata_entries(primary).await? { - return Err(format!( - "refusing to move non-empty download directory '{}' to Trash without explicit ownership", - primary.display() - )); - } - // Download directories must follow the same Trash contract as - // files. Do not fall back to recursive permanent deletion. - trash::delete(primary).map_err(|e| e.to_string()) - } else { - if let Err(e) = trash::delete(primary) { - log::warn!("failed to move downloaded file to Trash, attempting hard delete: {}", e); - std::fs::remove_file(primary).map_err(|e| e.to_string()) - } else { - Ok(()) - } - }; - match res { - Ok(_) => break, - Err(e) => { - if retries == 0 { - return Err(e); - } - retries -= 1; - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - } - } - } - } - - remove_download_sidecars(primary, app_handle).await?; - - cleanup_media_processing_artifacts(primary).await; - Ok(()) + let mut cleanup_targets = vec![primary.to_path_buf()]; + cleanup_targets.extend(download_sidecar_paths(primary)); + cleanup_targets.extend(collect_media_processing_artifact_paths(primary, app_handle).await?); + let _target_guards = lock_download_targets(&mut cleanup_targets).await; + remove_download_assets_locked(primary, app_handle).await } -async fn remove_download_sidecars( +async fn remove_download_assets_locked( primary: &std::path::Path, app_handle: &tauri::AppHandle, ) -> Result<(), String> { - for suffix in [".aria2", ".part", ".ytdl"] { - let mut candidate_os = primary.as_os_str().to_os_string(); - candidate_os.push(suffix); - let candidate = std::path::PathBuf::from(candidate_os); - if candidate.exists() && is_safe_path(&candidate, app_handle) { - let mut retries = 5; - loop { - match tokio::fs::remove_file(&candidate).await { - Ok(_) => break, - Err(_) if retries == 0 => { - return Err(format!("failed to remove '{}' after retries", candidate.display())); - } - Err(_) => { - retries -= 1; - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - } + let snapshot = capture_trash_target_snapshot(primary, app_handle).await?; + let expected_owner = crate::download_ownership::owner_for_path(app_handle, primary)?; + let sidecars = download_sidecar_paths(primary); + let artifacts = validate_media_artifact_targets(app_handle, expected_owner.as_deref(), primary).await?; + let mut protected_targets = sidecars.clone(); + protected_targets.extend(artifacts.clone()); + protected_targets.push(primary.to_path_buf()); + for target in &protected_targets { + validate_cleanup_target_owner(app_handle, target, expected_owner.as_deref())?; + } + + if let Some(snapshot) = snapshot { + let mut retries = 5; + loop { + match trash_delete_with_snapshot(primary, app_handle, &snapshot) { + Ok(()) => break, + Err(TrashAttemptError::Validation(error)) => return Err(error), + Err(TrashAttemptError::Operation { kind }) if retries == 0 => { + log::warn!( + "download asset Trash operation failed [attempt=6 error_kind={}]", + kind + ); + return Err(trash_failure_message("download asset")); + } + Err(TrashAttemptError::Operation { kind }) => { + log::warn!( + "download asset Trash operation failed [attempt={} error_kind={}]", + 6 - retries, + kind + ); + retries -= 1; + } + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + } + + remove_download_sidecars_locked(primary, app_handle, expected_owner.as_deref()).await?; + cleanup_media_artifacts_locked( + app_handle, + expected_owner.as_deref(), + primary, + false, + ) + .await?; + Ok(()) +} + +async fn remove_download_sidecars_locked( + primary: &std::path::Path, + app_handle: &tauri::AppHandle, + expected_owner: Option<&str>, +) -> Result<(), String> { + for candidate in download_sidecar_paths(primary) { + validate_cleanup_target_owner(app_handle, &candidate, expected_owner)?; + let Some(snapshot) = capture_regular_file_snapshot(&candidate, app_handle).await? else { + continue; + }; + for attempt in 0..=5 { + if !validate_regular_file_snapshot(&candidate, app_handle, &snapshot)? { + return Err(format!( + "download sidecar '{}' disappeared during cleanup; preserving ownership", + candidate.display() + )); + } + match run_trash_operation(|| std::fs::remove_file(&candidate)) { + Ok(()) => break, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(format!( + "download sidecar '{}' disappeared during cleanup; preserving ownership", + candidate.display() + )); + } + Err(error) if attempt == 5 => { + return Err(format!( + "failed to remove '{}' after retries: {error}", + candidate.display() + )); + } + Err(_) => { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; } } } @@ -7124,11 +7646,21 @@ async fn remove_exact_file_permanently( path.display() )); } - None => return Ok(()), + None => { + return Err(format!( + "refusing to permanently remove '{}' because it disappeared during cleanup", + path.display() + )); + } } - match tokio::fs::remove_file(path).await { + match run_trash_operation(|| std::fs::remove_file(path)) { Ok(()) => return Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(format!( + "refusing to permanently remove '{}' because it disappeared during cleanup", + path.display() + )); + } Err(error) if attempt == 5 => { return Err(format!( "could not permanently remove '{}' after retries: {error}", @@ -7186,6 +7718,10 @@ async fn remove_download_sidecars_permanently( primary: &std::path::Path, app_handle: &tauri::AppHandle, ) -> Result<(), String> { + let expected_owner = crate::download_ownership::owner_for_path(app_handle, primary)?; + for sidecar in download_sidecar_paths(primary) { + validate_cleanup_target_owner(app_handle, &sidecar, expected_owner.as_deref())?; + } validate_download_sidecars_permanently(primary, app_handle).await?; for suffix in [".aria2", ".part", ".ytdl"] { let mut candidate_os = primary.as_os_str().to_os_string(); @@ -7215,56 +7751,44 @@ async fn collect_media_processing_artifacts_for_permanent_removal, ) -> Result, String> { - let Some(parent) = primary.parent() else { - return Ok(Vec::new()); - }; - if !is_safe_path(parent, app_handle) { - return Err("download media artifact directory is outside an allowed location".to_string()); + let candidates = collect_media_processing_artifact_paths(primary, app_handle).await?; + for path in &candidates { + validate_exact_file_for_permanent_removal(path, app_handle).await?; } - let Some(base_name) = primary.file_name().and_then(|name| name.to_str()) else { - return Ok(Vec::new()); - }; - let base_stem = primary - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or(base_name); - let mut entries = match tokio::fs::read_dir(parent).await { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(error) => return Err(format!("could not inspect media artifacts: {error}")), - }; - let mut artifacts = Vec::new(); - while let Some(entry) = entries - .next_entry() - .await - .map_err(|error| format!("could not inspect media artifacts: {error}"))? - { - let path = entry.path(); - if crate::platform::paths_equal(&path, primary) { - continue; - } - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if !is_media_artifact_name(name, base_name, base_stem) { - continue; - } - validate_exact_file_for_permanent_removal(&path, app_handle).await?; - artifacts.push(path); - } - Ok(artifacts) + Ok(candidates) } +#[cfg(test)] async fn remove_download_assets_permanently( primary: &std::path::Path, app_handle: &tauri::AppHandle, ) -> Result<(), String> { + let mut initial_targets = vec![primary.to_path_buf()]; + initial_targets.extend(download_sidecar_paths(primary)); + let _target_guards = lock_download_targets(&mut initial_targets).await; + remove_download_assets_permanently_locked(primary, app_handle).await +} + +async fn remove_download_assets_permanently_locked( + primary: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result<(), String> { + // Reject links, special files, and unsafe paths before consulting the + // ownership registry, whose canonical-path lookup intentionally follows + // only trusted paths. + validate_exact_file_for_permanent_removal(primary, app_handle).await?; + validate_download_sidecars_permanently(primary, app_handle).await?; let artifacts = collect_media_processing_artifacts_for_permanent_removal(primary, app_handle).await?; + let expected_owner = crate::download_ownership::owner_for_path(app_handle, primary)?; + let mut protected_targets = download_sidecar_paths(primary); + protected_targets.extend(artifacts.iter().cloned()); + protected_targets.push(primary.to_path_buf()); + for target in &protected_targets { + validate_cleanup_target_owner(app_handle, target, expected_owner.as_deref())?; + } // Validate every exact sidecar before deleting the primary payload. A // symlink or special-file substitution must leave the row/ownership // intact rather than producing a partially-cleaned download silently. - validate_download_sidecars_permanently(primary, app_handle).await?; - validate_exact_file_for_permanent_removal(primary, app_handle).await?; remove_exact_file_permanently(primary, app_handle).await?; remove_download_sidecars_permanently(primary, app_handle).await?; for artifact in artifacts { @@ -7273,18 +7797,20 @@ async fn remove_download_assets_permanently( Ok(()) } -async fn remove_download_container_assets_permanently( +#[derive(Debug, Default)] +struct TorrentContainerCleanupTargets { + metadata_files: Vec, + empty_directories: Vec, + has_unrelated_entries: bool, +} + +async fn collect_torrent_container_cleanup_targets( primary: &std::path::Path, app_handle: &tauri::AppHandle, -) -> Result<(), String> { +) -> Result, String> { let metadata = match tokio::fs::symlink_metadata(primary).await { Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - // The output directory may already be gone while Aria2's exact - // sidecars remain. They are still safe to remove by name. - remove_download_sidecars_permanently(primary, app_handle).await?; - return Ok(()); - } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(format!("could not inspect Torrent output directory: {error}")), }; if metadata_is_link_or_reparse(&metadata) || path_has_symlink_component(primary) { @@ -7303,29 +7829,16 @@ async fn remove_download_container_assets_permanently( return Err("Torrent output directory is outside an allowed download location".to_string()); } - // Validate sidecars before any exact entry is removed. The removal helper - // revalidates again under the lifecycle/target lock immediately before - // unlinking, so a later substitution still fails closed. - validate_download_sidecars_permanently(primary, app_handle).await?; - - // Inspect the complete tree without following links. If a real unowned - // entry is found, stop before deleting anything in the container. This - // preserves the whole user-owned subtree rather than trying to clean - // around it. let mut pending = vec![(primary.to_path_buf(), false)]; - let mut empty_directories = Vec::new(); - let mut metadata_files = Vec::new(); - let mut has_unrelated_entries = false; + let mut targets = TorrentContainerCleanupTargets::default(); 'inspect: while let Some((directory, visited)) = pending.pop() { if visited { - empty_directories.push(directory); + targets.empty_directories.push(directory); continue; } let mut entries = match tokio::fs::read_dir(&directory).await { Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - continue; - } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, Err(error) => { return Err(format!( "could not inspect Torrent output directory '{}': {error}", @@ -7365,10 +7878,9 @@ async fn remove_download_container_assets_permanently( path.display() )); } - validate_exact_file_for_permanent_removal(&path, app_handle).await?; - metadata_files.push(path); + targets.metadata_files.push(path); } else { - has_unrelated_entries = true; + targets.has_unrelated_entries = true; break 'inspect; } } @@ -7381,11 +7893,66 @@ async fn remove_download_container_assets_permanently( .map(|child| (child, false)), ); } + Ok(Some(targets)) +} - // Sidecars are still exact, independently owned assets, so remove them - // even when an unrelated entry means that the output container remains. +fn torrent_container_descendant_paths( + targets: &TorrentContainerCleanupTargets, +) -> Vec { + targets + .metadata_files + .iter() + .chain(targets.empty_directories.iter()) + .cloned() + .collect() +} + +#[cfg(test)] +async fn remove_download_container_assets_permanently( + primary: &std::path::Path, + app_handle: &tauri::AppHandle, +) -> Result<(), String> { + let mut initial_targets = vec![primary.to_path_buf()]; + initial_targets.extend(download_sidecar_paths(primary)); + let _target_guards = lock_download_targets(&mut initial_targets).await; + remove_download_container_assets_permanently_locked(primary, app_handle, &initial_targets).await +} + +async fn remove_download_container_assets_permanently_locked( + primary: &std::path::Path, + app_handle: &tauri::AppHandle, + held_paths: &[std::path::PathBuf], +) -> Result<(), String> { + let Some(targets) = collect_torrent_container_cleanup_targets(primary, app_handle).await? + else { + // The output directory may already be gone while Aria2's exact + // sidecars remain. They are still safe to remove by name. + remove_download_sidecars_permanently(primary, app_handle).await?; + return Ok(()); + }; + + let expected_owner = crate::download_ownership::owner_for_path(app_handle, primary)?; + for sidecar in download_sidecar_paths(primary) { + validate_cleanup_target_owner(app_handle, &sidecar, expected_owner.as_deref())?; + } + + let mut descendant_paths = torrent_container_descendant_paths(&targets); + let _descendant_guards = + lock_download_targets_excluding(&mut descendant_paths, held_paths).await; + + for path in torrent_container_descendant_paths(&targets) { + validate_cleanup_target_owner(app_handle, &path, expected_owner.as_deref())?; + } + for path in &targets.metadata_files { + validate_exact_file_for_permanent_removal(path, app_handle).await?; + } + + // Validate sidecars before any exact entry is removed. The removal helper + // revalidates again under the lifecycle/target lock immediately before + // unlinking, so a later substitution still fails closed. + validate_download_sidecars_permanently(primary, app_handle).await?; remove_download_sidecars_permanently(primary, app_handle).await?; - if has_unrelated_entries { + if targets.has_unrelated_entries { log::debug!( "keeping Torrent output directory '{}': unrelated entries remain", primary.display() @@ -7393,7 +7960,7 @@ async fn remove_download_container_assets_permanently( return Ok(()); } - for path in metadata_files { + for path in targets.metadata_files { remove_exact_file_permanently(&path, app_handle).await?; } @@ -7401,7 +7968,7 @@ async fn remove_download_container_assets_permanently( // directories before their parents, so a file or directory added after // inspection makes that directory (and then its parents) fail closed // without deleting newly-created unowned content. - for directory in &empty_directories { + for directory in &targets.empty_directories { let removed = remove_empty_directory_permanently(directory).await?; if crate::platform::paths_equal(directory, primary) && !removed { log::debug!( @@ -7411,7 +7978,8 @@ async fn remove_download_container_assets_permanently( } } - if !empty_directories + if !targets + .empty_directories .iter() .any(|directory| crate::platform::paths_equal(directory, primary)) { @@ -7491,46 +8059,34 @@ fn is_os_directory_metadata(name: &std::ffi::OsStr) -> bool { } async fn directory_has_non_metadata_entries(path: &std::path::Path) -> Result { + let path = path.to_path_buf(); + tokio::task::spawn_blocking(move || directory_has_non_metadata_entries_sync(&path)) + .await + .map_err(|_| "could not inspect download directory".to_string())? + .map_err(|_| "could not inspect download directory".to_string()) +} + +fn directory_has_non_metadata_entries_sync(path: &std::path::Path) -> Result { let mut directories = vec![path.to_path_buf()]; while let Some(directory) = directories.pop() { - let mut entries = match tokio::fs::read_dir(&directory).await { + let entries = match std::fs::read_dir(&directory) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - return Err(format!( - "could not inspect directory '{}': {}", - directory.display(), - error - )); - } + Err(error) => return Err(error), }; - while let Some(entry) = entries.next_entry().await.map_err(|error| { - format!( - "could not inspect directory '{}': {}", - directory.display(), - error - ) - })? { - let metadata = match tokio::fs::symlink_metadata(entry.path()).await { + for entry in entries { + let entry = entry?; + let metadata = match std::fs::symlink_metadata(entry.path()) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - return Err(format!( - "could not inspect directory entry '{}': {}", - entry.path().display(), - error - )); - } + Err(error) => return Err(error), }; if metadata_is_link_or_reparse(&metadata) { return Ok(true); } if metadata.is_dir() { directories.push(entry.path()); - } else if is_os_directory_metadata(&entry.file_name()) { - // These names are ignorable only for regular OS metadata - // files. A directory with a metadata-like name may contain - // real user content and must be inspected normally. + } else if is_os_directory_metadata(&entry.file_name()) && metadata.is_file() { continue; } else { return Ok(true); @@ -7542,7 +8098,7 @@ async fn directory_has_non_metadata_entries(path: &std::path::Path) -> Result Result { for attempt in 0..=5 { - match tokio::fs::remove_dir(path).await { + match run_trash_operation(|| std::fs::remove_dir(path)) { Ok(()) => return Ok(true), Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(true), Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => { @@ -7560,61 +8116,73 @@ async fn remove_empty_directory_permanently(path: &std::path::Path) -> Result( +async fn remove_download_container_assets_locked( primary: &std::path::Path, app_handle: &tauri::AppHandle, + held_paths: &[std::path::PathBuf], ) -> Result<(), String> { - if !is_safe_path(primary, app_handle) { - return Err("Download asset path is outside an allowed download location".to_string()); - } - // The primary path of a multi-file Torrent is its output directory, while - // owned_paths contains only the selected files. Remove the directory's - // Aria2 sidecars, then move the directory itself to Trash only when no - // unowned files remain inside it. - remove_download_sidecars(primary, app_handle).await?; - let is_directory = std::fs::symlink_metadata(primary) - .map(|metadata| metadata.file_type().is_dir()) - .unwrap_or(false); - if !is_directory { + // owned_paths contains only the selected files. Move the directory itself + // to Trash only when no unowned files remain inside it. Sidecars are + // removed after a successful Trash move so a failure preserves the full + // recoverable asset set. + let snapshot = capture_trash_target_snapshot(primary, app_handle).await?; + let expected_owner = crate::download_ownership::owner_for_path(app_handle, primary)?; + for sidecar in download_sidecar_paths(primary) { + validate_cleanup_target_owner(app_handle, &sidecar, expected_owner.as_deref())?; + } + let Some(snapshot) = snapshot else { + remove_download_sidecars_locked(primary, app_handle, expected_owner.as_deref()).await?; + return Ok(()); + }; + if !matches!(&snapshot, TrashTargetSnapshot::Directory { .. }) { + remove_download_sidecars_locked(primary, app_handle, expected_owner.as_deref()).await?; return Ok(()); } - if directory_has_non_metadata_entries(primary).await? { + + let targets = collect_torrent_container_cleanup_targets(primary, app_handle) + .await? + .ok_or_else(|| "Torrent output directory disappeared during cleanup".to_string())?; + let mut descendant_paths = torrent_container_descendant_paths(&targets); + let _descendant_guards = + lock_download_targets_excluding(&mut descendant_paths, held_paths).await; + for path in torrent_container_descendant_paths(&targets) { + validate_cleanup_target_owner(app_handle, &path, expected_owner.as_deref())?; + } + + if targets.has_unrelated_entries || directory_has_non_metadata_entries(primary).await? { log::debug!( - "keeping non-empty Torrent output directory '{}': it contains files outside the selected ownership set", - primary.display() + "keeping non-empty Torrent output directory because it contains files outside the selected ownership set" ); + remove_download_sidecars_locked(primary, app_handle, expected_owner.as_deref()).await?; return Ok(()); } let mut retries = 5; loop { - match trash::delete(primary) { + match trash_delete_with_snapshot(primary, app_handle, &snapshot) { Ok(()) => break, - Err(error) => { - if directory_has_non_metadata_entries(primary).await? { - log::debug!( - "keeping non-empty Torrent output directory '{}': {}", - primary.display(), - error - ); - break; - } - if !primary.exists() { - return Ok(()); - } - if retries == 0 { - return Err(format!( - "failed to move Torrent output directory '{}' to Trash: {}", - primary.display(), - error - )); - } + Err(TrashAttemptError::Validation(error)) => return Err(error), + Err(TrashAttemptError::Operation { kind }) if retries == 0 => { + log::warn!( + "Torrent output Trash operation failed [attempt=6 error_kind={}]", + kind + ); + return Err(trash_failure_message("Torrent output directory")); + } + Err(TrashAttemptError::Operation { kind }) => { + log::warn!( + "Torrent output Trash operation failed [attempt={} error_kind={}]", + 6 - retries, + kind + ); retries -= 1; - tokio::time::sleep(std::time::Duration::from_millis(200)).await; } } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; } + + remove_download_sidecars_locked(primary, app_handle, expected_owner.as_deref()).await?; Ok(()) } @@ -9771,7 +10339,8 @@ async fn enqueue_download_locked( }; let target_lock_path = enqueue_output_lock_path(app_handle, &item) .map_err(AppError::Internal)?; - let _target_guard = Some(download_target_lock(&target_lock_path).await.lock_owned().await); + let mut target_lock_paths = vec![target_lock_path]; + let _target_guards = lock_download_targets(&mut target_lock_paths).await; let lifecycle_generation = enqueue_lifecycle_generation(&item).map_err(AppError::Internal)?; let previous_generation = state .queue_manager @@ -9998,7 +10567,8 @@ async fn enqueue_many( continue; } }; - let _target_guard = download_target_lock(&target_lock_path).await.lock_owned().await; + let mut target_lock_paths = vec![target_lock_path]; + let _target_guards = lock_download_targets(&mut target_lock_paths).await; let lifecycle_generation = match enqueue_lifecycle_generation(&item) { Ok(generation) => generation, Err(error) => { @@ -14291,6 +14861,7 @@ mod tests { cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present, metadata_headers, metadata_response_error, metadata_error_code, + reqwest_error_code, is_remote_torrent_source, normalize_speed_limit_for_aria2, normalize_torrent_overall_upload_limit, @@ -14308,7 +14879,8 @@ mod tests { normalize_opened_torrent_argument, redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value, has_resumable_download_assets, is_media_artifact_name, - directory_has_non_metadata_entries, primary_path_needs_container_cleanup, + directory_has_non_metadata_entries, directory_has_non_metadata_entries_sync, + primary_path_needs_container_cleanup, should_cleanup_media_artifacts_after_failure, should_retry_without_browser_cookies, retry_metadata_with_cookies, should_retry_metadata_with_cookies, @@ -14337,7 +14909,10 @@ mod tests { download_asset_or_sidecar_exists, classify_download_target, download_target_lock, inspect_unowned_replacement_target, remove_unowned_replacement_target, - remove_download_assets_permanently, remove_download_container_assets_permanently, + capture_trash_target_snapshot, trash_delete_with_snapshot, + remove_download_assets, remove_download_assets_permanently, + remove_download_container_assets_permanently, run_trash_operation, + set_test_trash_failures, restore_download_replacement, legacy_target_fingerprint, target_fingerprint, target_fingerprint_matches, DownloadReplacementReservation, }; @@ -14364,6 +14939,69 @@ mod tests { assert!(!aria2_gid_not_found("aria2 error code 3: Resource not found")); } + #[tokio::test] + async fn reqwest_dns_failures_use_the_typed_dns_error_classification() { + struct FailingResolver; + + impl reqwest::dns::Resolve for FailingResolver { + fn resolve(&self, _name: reqwest::dns::Name) -> reqwest::dns::Resolving { + Box::pin(async { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "injected DNS failure", + ) + .into()) + }) + } + } + + super::ensure_reqwest_crypto_provider(); + let client = reqwest::Client::builder() + .dns_resolver(FailingResolver) + .build() + .expect("client with injected resolver should build"); + let error = client + .get("http://dns-classification.invalid/") + .send() + .await + .expect_err("the injected resolver must fail"); + + assert!(error.is_dns()); + assert_eq!(reqwest_error_code(&error), "dns"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn trash_operation_keeps_lifecycle_guard_through_cancellation_attempt() { + use std::sync::{mpsc, Arc, Mutex}; + + let lifecycle_lock = Arc::new(Mutex::new(())); + let lock_for_task = Arc::clone(&lifecycle_lock); + let lock_for_operation = Arc::clone(&lifecycle_lock); + let (started_sender, started_receiver) = mpsc::sync_channel(0); + let (release_sender, release_receiver) = mpsc::sync_channel(0); + let (finished_sender, finished_receiver) = mpsc::sync_channel(0); + + let task = tokio::spawn(async move { + let _lifecycle_guard = lock_for_task.lock().unwrap(); + run_trash_operation(move || { + started_sender.send(()).unwrap(); + release_receiver.recv().unwrap(); + assert!(lock_for_operation.try_lock().is_err()); + finished_sender.send(()).unwrap(); + }); + }); + + started_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("Trash operation should start"); + task.abort(); + release_sender.send(()).unwrap(); + finished_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("the blocking operation must finish after cancellation is attempted"); + assert!(task.await.is_ok()); + } + #[test] fn stale_lifecycle_cleanup_only_targets_the_expected_native_owner() { assert!(!stale_lifecycle_cleanup_is_noop(Some(7), Some(7))); @@ -14717,6 +15355,222 @@ mod tests { assert_eq!(std::fs::read(&target).unwrap(), b"new file"); } + #[tokio::test] + async fn trash_failure_preserves_payload_sidecars_and_ownership() { + use tauri::Manager; + + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let storage_layout = crate::storage::StorageLayout::resolve( + app.handle(), + crate::storage::StorageMode::Portable { + root: storage_root.path().to_path_buf(), + }, + ) + .unwrap(); + app.manage(crate::db::init(&storage_layout).unwrap()); + let download_root = + configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); + let directory = tempfile::tempdir_in(&download_root).unwrap(); + let primary = directory.path().join("owned.bin"); + std::fs::write(&primary, b"payload").unwrap(); + for suffix in [".aria2", ".part", ".ytdl"] { + let mut path = primary.as_os_str().to_os_string(); + path.push(suffix); + std::fs::write(path, b"sidecar").unwrap(); + } + crate::download_ownership::set_primary_path(app.handle(), "trash-owner", &primary) + .unwrap(); + set_test_trash_failures(&primary, 6); + + let error = remove_download_assets(&primary, app.handle()) + .await + .expect_err("repeated Trash failures must stop without permanent deletion"); + + assert!(error.contains("permanent deletion was not attempted")); + assert!(primary.exists()); + for suffix in [".aria2", ".part", ".ytdl"] { + let mut path = primary.as_os_str().to_os_string(); + path.push(suffix); + assert!(std::path::PathBuf::from(path).exists()); + } + assert_eq!( + crate::download_ownership::owner_for_path(app.handle(), &primary) + .unwrap() + .as_deref(), + Some("trash-owner") + ); + + set_test_trash_failures(&primary, 0); + crate::download_ownership::remove(app.handle(), "trash-owner").unwrap(); + } + + #[tokio::test] + async fn cleanup_preserves_a_sidecar_owned_by_another_download() { + use tauri::Manager; + + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let storage_layout = crate::storage::StorageLayout::resolve( + app.handle(), + crate::storage::StorageMode::Portable { + root: storage_root.path().to_path_buf(), + }, + ) + .unwrap(); + app.manage(crate::db::init(&storage_layout).unwrap()); + let download_root = + configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); + let primary = download_root.join("owned.bin"); + let sidecar = download_root.join("owned.bin.aria2"); + std::fs::write(&primary, b"payload").unwrap(); + std::fs::write(&sidecar, b"sidecar").unwrap(); + crate::download_ownership::set_primary_path(app.handle(), "primary-owner", &primary) + .unwrap(); + crate::download_ownership::set_primary_path(app.handle(), "sidecar-owner", &sidecar) + .unwrap(); + + let error = remove_download_assets(&primary, app.handle()) + .await + .expect_err("cleanup must stop before deleting an owned sidecar"); + assert!(error.contains("owned by Firelink download sidecar-owner")); + assert!(primary.exists()); + assert!(sidecar.exists()); + + crate::download_ownership::remove(app.handle(), "primary-owner").unwrap(); + crate::download_ownership::remove(app.handle(), "sidecar-owner").unwrap(); + } + + #[tokio::test] + async fn cleanup_preserves_a_media_artifact_owned_by_another_download() { + use tauri::Manager; + + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let storage_layout = crate::storage::StorageLayout::resolve( + app.handle(), + crate::storage::StorageMode::Portable { + root: storage_root.path().to_path_buf(), + }, + ) + .unwrap(); + app.manage(crate::db::init(&storage_layout).unwrap()); + let download_root = + configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); + let primary = download_root.join("video.mp4"); + let artifact = download_root.join("video.f137.mp4"); + std::fs::write(&primary, b"payload").unwrap(); + std::fs::write(&artifact, b"artifact").unwrap(); + crate::download_ownership::set_primary_path(app.handle(), "primary-owner", &primary) + .unwrap(); + crate::download_ownership::set_primary_path(app.handle(), "artifact-owner", &artifact) + .unwrap(); + + let error = remove_download_assets(&primary, app.handle()) + .await + .expect_err("cleanup must stop before deleting an owned media artifact"); + assert!(error.contains("owned by Firelink download artifact-owner")); + assert!(primary.exists()); + assert!(artifact.exists()); + + crate::download_ownership::remove(app.handle(), "primary-owner").unwrap(); + crate::download_ownership::remove(app.handle(), "artifact-owner").unwrap(); + } + + #[tokio::test] + async fn replacement_trash_failure_preserves_file_and_rejects_identity_change() { + use tauri::Manager; + + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let storage_layout = crate::storage::StorageLayout::resolve( + app.handle(), + crate::storage::StorageMode::Portable { + root: storage_root.path().to_path_buf(), + }, + ) + .unwrap(); + app.manage(crate::db::init(&storage_layout).unwrap()); + let download_root = + configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); + let target = download_root.join("replacement.bin"); + std::fs::write(&target, b"original").unwrap(); + let fingerprint = + target_fingerprint(&target, &std::fs::symlink_metadata(&target).unwrap()).unwrap(); + + set_test_trash_failures(&target, 6); + let error = remove_unowned_replacement_target( + &target, + &fingerprint, + "staged-replacement", + false, + app.handle(), + ) + .await + .expect_err("repeated replacement Trash failures must preserve the target"); + assert!(error.contains("permanent deletion was not attempted")); + assert_eq!(std::fs::read(&target).unwrap(), b"original"); + + set_test_trash_failures(&target, 0); + std::fs::write(&target, b"changed").unwrap(); + let error = remove_unowned_replacement_target( + &target, + &fingerprint, + "staged-replacement", + false, + app.handle(), + ) + .await + .expect_err("a changed replacement must fail closed before Trash"); + assert!(error.contains("changed after duplicate resolution")); + assert_eq!(std::fs::read(&target).unwrap(), b"changed"); + } + + #[tokio::test] + async fn ordinary_trash_snapshot_rejects_a_replaced_file_before_trash() { + use tauri::Manager; + + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let storage_layout = crate::storage::StorageLayout::resolve( + app.handle(), + crate::storage::StorageMode::Portable { + root: storage_root.path().to_path_buf(), + }, + ) + .unwrap(); + app.manage(crate::db::init(&storage_layout).unwrap()); + let download_root = + configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); + let directory = tempfile::tempdir_in(&download_root).unwrap(); + let target = directory.path().join("download.bin"); + let original = directory.path().join("download.old"); + std::fs::write(&target, b"original").unwrap(); + let snapshot = capture_trash_target_snapshot(&target, app.handle()) + .await + .unwrap() + .expect("existing regular file should have a Trash snapshot"); + + std::fs::rename(&target, &original).unwrap(); + std::fs::write(&target, b"replaced").unwrap(); + let error = trash_delete_with_snapshot(&target, app.handle(), &snapshot) + .expect_err("a replacement must fail snapshot validation before Trash"); + + assert!(matches!(error, super::TrashAttemptError::Validation(_))); + assert_eq!(std::fs::read(&target).unwrap(), b"replaced"); + assert_eq!(std::fs::read(&original).unwrap(), b"original"); + } + #[test] fn terminal_aria2_status_preserves_exact_progress_snapshot() { let snapshot = aria2_download_state_progress(Some(&json!({ @@ -16858,6 +17712,20 @@ mod tests { assert!(!directory_has_non_metadata_entries(directory.path()) .await .unwrap()); + #[cfg(unix)] + { + use std::os::unix::net::UnixListener; + + std::fs::remove_file(directory.path().join(".DS_Store")).unwrap(); + let socket_path = directory.path().join(".DS_Store"); + let socket = UnixListener::bind(&socket_path).unwrap(); + assert!(directory_has_non_metadata_entries(directory.path()) + .await + .unwrap()); + assert!(directory_has_non_metadata_entries_sync(directory.path()).unwrap()); + drop(socket); + std::fs::remove_file(socket_path).unwrap(); + } let empty_nested = directory.path().join("MD5").join("nested"); std::fs::create_dir_all(&empty_nested).unwrap(); assert!(!directory_has_non_metadata_entries(directory.path()) @@ -17352,6 +18220,70 @@ mod tests { drop(directory); } + #[tokio::test] + async fn permanent_torrent_container_preserves_owned_descendants() { + use tauri::Manager; + + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let storage_root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let storage_layout = crate::storage::StorageLayout::resolve( + app.handle(), + crate::storage::StorageMode::Portable { + root: storage_root.path().to_path_buf(), + }, + ) + .unwrap(); + app.manage(crate::db::init(&storage_layout).unwrap()); + let download_root = + configure_test_download_root(app.handle(), &storage_root.path().join("downloads")); + let directory = tempfile::tempdir_in(&download_root).unwrap(); + let container = directory.path().join("torrent-output"); + let nested = container.join("nested"); + let metadata = container.join(".DS_Store"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(&metadata, b"metadata").unwrap(); + crate::download_ownership::set_owned_paths_with_primary_and_removal( + app.handle(), + "container-owner", + &container, + &[container.join("owned.bin")], + &[], + ) + .unwrap(); + crate::download_ownership::set_primary_path(app.handle(), "metadata-owner", &metadata) + .unwrap(); + + let error = remove_download_container_assets_permanently(&container, app.handle()) + .await + .expect_err("owned metadata must block container cleanup"); + assert!(error.contains("metadata-owner")); + assert!(container.is_dir()); + assert!(metadata.exists()); + assert!(nested.is_dir()); + + crate::download_ownership::remove(app.handle(), "metadata-owner").unwrap(); + crate::download_ownership::set_owned_paths_with_primary_and_removal( + app.handle(), + "nested-owner", + &nested, + &[nested.join("owned.bin")], + &[], + ) + .unwrap(); + let error = remove_download_container_assets_permanently(&container, app.handle()) + .await + .expect_err("owned empty descendant directories must block cleanup"); + assert!(error.contains("nested-owner")); + assert!(container.is_dir()); + assert!(nested.is_dir()); + + crate::download_ownership::remove(app.handle(), "container-owner").unwrap(); + crate::download_ownership::remove(app.handle(), "nested-owner").unwrap(); + drop(directory); + } + #[tokio::test] async fn permanent_torrent_container_cleanup_removes_empty_nested_directories() { use tauri::Manager;