diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9a722f..5b7ca66b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ the app. ## Unreleased +### Plugins with a native helper no longer need a new app release + +A plugin that ships a native helper program (a "sidecar") used to work only if its +name was hard-coded into the app. Now, the first time such a plugin runs its helper, +DevTool shows a native confirmation dialog with the program's path and SHA-256 and +asks whether to allow it. Your answer is remembered in the OS keychain for that exact +file — a different build of the helper asks again, and uninstalling the plugin forgets +the decision. Helpers bundled with DevTool itself (Redis, RabbitMQ, Container Manager, +Kafka Explorer) run without asking, as before. See +`docs/plugin-sdk/01-architecture.md`. + ### ⚠️ Breaking: Redis, RabbitMQ, Container Manager, and Kafka Explorer are now optional plugins These four tools used to ship built into the app. As of this release they've been diff --git a/docs/plugin-sdk/01-architecture.md b/docs/plugin-sdk/01-architecture.md index 4dadfac0..40b254d1 100644 --- a/docs/plugin-sdk/01-architecture.md +++ b/docs/plugin-sdk/01-architecture.md @@ -72,13 +72,29 @@ mật — nó không phải, và biết chính xác nó LÀ gì quan trọng hơ phải rào chắn runtime. Nó biến "quyền là mô tả" thành "quyền là hàng rào thật" theo nghĩa: nếu không ai lách qua SDK, thì allowlist trong manifest đúng là nơi DUY NHẤT quyết định một lời gọi có đi tiếp hay không. -- **Ranh giới thật, có giá trị bảo mật, nằm ở Tier B:** `ALLOWED_SERVICES` - trong `service_host.rs` là một hằng số Rust CỨNG, quyết định lúc BIÊN - DỊCH — không đổi được lúc chạy, không do manifest hay webview quyết định. - Một plugin khai `service: { bin: 'ten-la' }` mà `ten-la` không nằm trong - `ALLOWED_SERVICES` thì lời gọi bị từ chối, bất kể manifest nói gì. Đây là - lý do: manifest do webview đọc — thứ ta đang muốn giới hạn khả năng của - nó — nên nó không thể là nơi cấp quyền chạy tiến trình. +- **Ranh giới thật, có giá trị bảo mật, nằm ở Tier B:** quyền chạy một tiến + trình KHÔNG do manifest hay webview quyết định — manifest do webview đọc, + thứ ta đang muốn giới hạn khả năng của nó, nên nó không thể là nơi cấp + quyền. Có đúng hai nguồn quyền, cả hai nằm ở Rust (`service_host.rs`): + 1. `ALLOWED_SERVICES` — hằng số CỨNG, quyết định lúc BIÊN DỊCH, cho các + sidecar DevTool tự đóng gói. Chạy không hỏi. + 2. **Sự đồng ý của người dùng** — với mọi tên khác dạng + `devtool-svc-`, lần đầu chạy app hiện hộp thoại native + (đường dẫn + SHA-256 của file sắp chạy) và hỏi. Hộp thoại do Rust bật, + webview chỉ kích hoạt được câu hỏi chứ không trả lời thay được. Nhờ đó + một plugin mới kèm sidecar không đòi phát hành lại app. + + **Quyết định đồng ý lưu ở keychain của OS, không phải file trong + `app_data`:** capability mặc định cho webview ghi tự do vào `app_data` + (`fs:allow-write-file` + `fs:scope-appdata-recursive`), nên mọi thứ ở đó — + kể cả `extensions/index.json` — webview tự sửa được. Mỗi mục gắn với SHA-256 + của file: binary bị thay hay cập nhật thì hỏi lại. Máy không có keychain thì + quyết định chỉ sống trong phiên (hỏi thừa, không bao giờ hỏi thiếu). Gỡ + cài đặt thu hồi quyết định. + + Giới hạn cần biết: đồng ý là đồng ý chạy với đầy đủ quyền của người dùng, + không có sandbox; và mã băm được tính ngay trước khi spawn, không giữ file + khoá nên về lý thuyết có cửa sổ thời gian rất ngắn giữa băm và chạy. - **Vì sao không cần sandbox chống mã độc lúc này:** mọi plugin (kể cả plugin cài từ URL bên ngoài) hiện do chính người phát hành DevTool viết và tự host. Đây không phải kho plugin bên thứ ba. Rủi ro thật đang được diff --git a/docs/plugin-sdk/02-manifest.md b/docs/plugin-sdk/02-manifest.md index 44c7c0ee..88be6100 100644 --- a/docs/plugin-sdk/02-manifest.md +++ b/docs/plugin-sdk/02-manifest.md @@ -148,6 +148,8 @@ export default definePlugin({ }); ``` -`service.bin` phải khớp một mục trong `bundle.externalBin` của -`tauri.conf.json` VÀ nằm trong `ALLOWED_SERVICES` (hằng số Rust) — xem +Sidecar cài qua URL chỉ cần `service.bin` dạng `devtool-svc-`; +người dùng được hỏi đồng ý lần đầu chạy. Chỉ sidecar DevTool đóng gói sẵn mới +cần khớp `bundle.externalBin` của `tauri.conf.json` và nằm trong +`ALLOWED_SERVICES` (hằng số Rust) — xem [04-tier-b-sidecars.md](./04-tier-b-sidecars.md) cho toàn bộ quy trình. diff --git a/docs/plugin-sdk/04-tier-b-sidecars.md b/docs/plugin-sdk/04-tier-b-sidecars.md index 35d94cd0..f42cc690 100644 --- a/docs/plugin-sdk/04-tier-b-sidecars.md +++ b/docs/plugin-sdk/04-tier-b-sidecars.md @@ -352,8 +352,16 @@ cho sidecar của bạn — không đọc/ghi ra ngoài nó, không đoán một ## Đăng ký sidecar vào app -Ba chỗ cần sửa — thiếu một trong ba thì sidecar build được, test được, nhưng -KHÔNG chạy được từ app thật: +**Sidecar chỉ cài qua URL (phần lớn plugin bên ngoài) KHÔNG cần sửa gì trong +app.** Tên bin chỉ cần có dạng `devtool-svc-`. Lần đầu plugin gọi +nó, app hiện một hộp thoại native với đường dẫn + SHA-256 của file sắp chạy và +hỏi người dùng có cho phép không (xem "Sự đồng ý của người dùng" ở +01-architecture.md). Đồng ý được nhớ trong keychain, gắn với mã băm: cập nhật +binary sang bản khác thì hỏi lại. + +Ba chỗ dưới đây chỉ dành cho sidecar do chính DevTool đóng gói sẵn, chạy +không hỏi — thiếu một trong ba thì sidecar build được, test được, nhưng KHÔNG +chạy được từ app thật: 1. **`src-tauri/src/service_host.rs`** — thêm tên bin vào `ALLOWED_SERVICES` (hằng số Rust, ranh giới tin cậy thật — xem 01-architecture.md). diff --git a/docs/plugin-sdk/05-external-install.md b/docs/plugin-sdk/05-external-install.md index eb2f692f..ac3a30da 100644 --- a/docs/plugin-sdk/05-external-install.md +++ b/docs/plugin-sdk/05-external-install.md @@ -223,13 +223,13 @@ Xem [04-tier-b-sidecars.md](./04-tier-b-sidecars.md) cho cách viết sidecar. 3. Host mỗi binary ở một URL `https://` (GitHub Release asset là lựa chọn đơn giản — public, ổn định, không cần hạ tầng riêng). 4. Viết manifest `kind: "service"` liệt kê từng target-triple → `{url, sha256}`. -5. Tên bin trong manifest (`bin`) phải khớp một tên đã có sẵn trong - `ALLOWED_SERVICES` của app đang cài — **cài qua URL KHÔNG tự cấp quyền - chạy cho một tên bin mới**; đây là ranh giới tin cậy cố ý (xem - 01-architecture.md). Sidecar bạn tự viết cho DevTool của chính mình thì - bạn cũng là người thêm tên vào `ALLOWED_SERVICES` khi build app — cơ chế - URL chỉ thay đổi CHỖ LẤY BYTES, không thay đổi AI QUYẾT ĐỊNH tên nào - được phép chạy. +5. Tên bin trong manifest (`bin`) phải có dạng `devtool-svc-`. + **Cài qua URL KHÔNG tự cấp quyền chạy** — manifest do webview đọc nên không + thể là nơi cấp quyền. Nếu tên không nằm trong `ALLOWED_SERVICES` của app + (danh sách các sidecar DevTool tự đóng gói), lần đầu được gọi app hỏi + người dùng qua hộp thoại native (đường dẫn + SHA-256), rồi nhớ quyết định + trong keychain gắn với mã băm. Không cần phát hành lại app cho mỗi plugin + mới; xem 01-architecture.md. ## Nơi lưu và độ ưu tiên lúc chạy diff --git a/package-lock.json b/package-lock.json index 0e648926..5665a23a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -671,9 +671,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -690,13 +690,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -713,13 +713,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -727,7 +727,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -737,9 +737,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -754,9 +754,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -771,9 +771,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -788,9 +788,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -805,9 +805,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -822,9 +822,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -839,9 +839,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -856,9 +856,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -873,9 +873,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -890,9 +890,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -907,9 +907,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -926,13 +926,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -949,13 +949,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -972,13 +972,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -995,13 +995,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -1018,13 +1018,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -1041,13 +1041,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -1064,13 +1064,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -1087,13 +1087,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, @@ -1108,9 +1108,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], @@ -1118,7 +1118,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1128,9 +1128,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -1148,9 +1148,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -1168,9 +1168,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -4472,9 +4472,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -6511,9 +6511,9 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6528,31 +6528,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -6568,9 +6568,9 @@ "license": "ISC" }, "node_modules/smol-toml": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", - "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -6580,9 +6580,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -6911,9 +6911,9 @@ } }, "node_modules/undici": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", - "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", "dev": true, "license": "MIT", "engines": { diff --git a/src-tauri/src/artifact_installer.rs b/src-tauri/src/artifact_installer.rs index 3d11df79..57f21728 100644 --- a/src-tauri/src/artifact_installer.rs +++ b/src-tauri/src/artifact_installer.rs @@ -408,7 +408,7 @@ fn write_index(dir: &Path, records: &[InstalledArtifactRecord]) -> Result<(), St std::fs::rename(&tmp_path, &final_path).map_err(|e| e.to_string()) } -fn sha256_hex(bytes: &[u8]) -> String { +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { to_hex(&sha2::Sha256::digest(bytes)) } @@ -744,6 +744,17 @@ pub fn installed_service_bin_path_at(index_dir: &Path, bin: &str) -> Option Option { + read_index(index_dir).ok()?.into_iter().find_map(|r| match r { + InstalledArtifactRecord::Service(s) if s.manifest.bin == bin => Some(s.source_url), + _ => None, + }) +} + // --------------------------------------------------------------------------- // current_target_triple — TÍNH Ở RUST, không nhận từ client/manifest // --------------------------------------------------------------------------- @@ -902,7 +913,9 @@ pub async fn artifact_installer_uninstall( )?; if let Some(bin) = stopped_bin { // Sidecar đang chạy (nếu có) bị dừng ngay — xem AC8. - let _ = crate::service_host::service_stop(services.clone(), bin).await; + let _ = crate::service_host::service_stop(services.clone(), bin.clone()).await; + // Gỡ cài đặt cũng thu hồi sự đồng ý: cài lại sau này phải được hỏi lại. + services.trust.revoke(&bin); } Ok(()) } @@ -973,6 +986,27 @@ mod tests { /// Không dùng dữ liệu bịa: đi qua đúng `InstalledPluginRecord` + /// `serde_json::to_string_pretty`, đúng những gì `write_index` bản cũ đã /// làm. + #[test] + fn nguon_cai_dat_cua_sidecar_doc_tu_index() { + let dir = temp_dir("source-url"); + assert_eq!(installed_service_source_url_at(&dir, "devtool-svc-x"), None, "no index yet"); + let record = InstalledArtifactRecord::Service(InstalledServiceRecord { + manifest: RemoteServiceManifest { + bin: "devtool-svc-x".into(), + version: "1.0.0".into(), + protocol: 1, + targets: HashMap::new(), + }, + source_url: "https://example.com/svc.json".into(), + bin_path: dir.join("x").to_string_lossy().into_owned(), + installed_at: 0, + market_id: None, + }); + write_index(&dir, &[record]).unwrap(); + assert_eq!(installed_service_source_url_at(&dir, "devtool-svc-x").as_deref(), Some("https://example.com/svc.json")); + assert_eq!(installed_service_source_url_at(&dir, "devtool-svc-y"), None); + } + #[test] fn doc_index_json_cu_khong_co_kind_tu_dong_thanh_toan_bo_plugin() { let old_record = InstalledPluginRecord { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 59c81bd1..b8face6e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,6 +10,7 @@ mod mockserver; mod ports; mod secrets_vault; mod service_host; +mod service_trust; mod artifact_installer; mod plugin_data; diff --git a/src-tauri/src/service_host.rs b/src-tauri/src/service_host.rs index bc93c07e..9c9e6976 100644 --- a/src-tauri/src/service_host.rs +++ b/src-tauri/src/service_host.rs @@ -32,9 +32,14 @@ // tắt, kể cả lúc app bị kill và không kịp chạy hàm dọn nào; // 3. không bao giờ ghi gì khác lên stdout (log thì ghi stderr). // -// Ranh giới tin cậy: frontend nêu tên binary, nhưng chỉ tên nằm trong -// `ALLOWED_SERVICES` dưới đây mới được chạy. Danh sách sống ở Rust, không phải -// ở manifest, vì manifest do webview đọc — thứ ta đang muốn giới hạn. +// Ranh giới tin cậy: frontend nêu tên binary, nhưng quyền chạy KHÔNG do +// frontend hay manifest quyết định (manifest do webview đọc — thứ ta đang muốn +// giới hạn). Có hai nguồn quyền, cả hai sống ở Rust: +// - `ALLOWED_SERVICES` dưới đây: các sidecar của chính DevTool, quyết định lúc +// build, chạy không hỏi; +// - sự đồng ý của người dùng qua hộp thoại native (`service_trust.rs`): với +// mọi sidecar khác, lần đầu chạy app hiện đường dẫn + SHA-256 và hỏi. Nhờ +// vậy thêm một plugin kèm sidecar không đòi phát hành lại app. use std::collections::HashMap; use std::process::Stdio; @@ -62,8 +67,10 @@ pub const SERVICE_PROTOCOL: u32 = 1; // timeout hợp lý cho một lời gọi qua IPC cục bộ" thì nên soát cả ba. const CALL_TIMEOUT: Duration = Duration::from_secs(30); -/// Binary sidecar được phép chạy — ranh giới tin cậy sống Ở ĐÂY, không phải -/// ở manifest (webview đọc được manifest, không đọc được hằng số Rust này). +/// Sidecar của chính DevTool, chạy KHÔNG hỏi — một trong hai nguồn quyền, cả hai ở +/// Rust chứ không ở manifest (webview đọc được manifest, không đọc được hằng số +/// này). Nguồn thứ hai là sự đồng ý của người dùng cho mọi tên khác, xem +/// `service_trust.rs`; thêm plugin mới KHÔNG cần sửa danh sách này. /// KHÔNG phải mọi mục ở đây được ĐÓNG GÓI SẴN — xem `BUNDLED_SERVICES` dưới, /// tách riêng đúng vì lý do đó. /// @@ -76,11 +83,9 @@ const CALL_TIMEOUT: Duration = Duration::from_secs(30); /// `devtool-svc-redis`/`-container`/`-rabbit`/`-kafka`: nguồn đã chuyển sang /// repo `developer-desktop-miniapp` (không phải ai cũng cần Redis/Docker/ /// RabbitMQ/Kafka) — xem docs/decisions/architecture/optional-broker-plugins.md. -/// Tên vẫn ở lại đây có chủ đích: `05-external-install.md` nói rõ "cài qua -/// URL KHÔNG tự cấp quyền chạy cho một tên bin mới" — cây quyết định "bin nào -/// được phép chạy" là của TÁC GIẢ lúc build app, không phải của người cài. Bỏ -/// tên khỏi danh sách này thì việc cài sidecar tương ứng từ URL sẽ luôn bị -/// allowlist từ chối, vĩnh viễn, không chỉ tới lúc cài xong. +/// Tên vẫn ở lại đây có chủ đích: người dùng của các plugin này đã quen việc +/// chúng chạy không hỏi, và bỏ tên khỏi danh sách sẽ khiến họ bị hỏi đồng ý ở lần +/// chạy kế tiếp sau khi nâng cấp app. const ALLOWED_SERVICES: &[&str] = &["devtool-svc-echo", "devtool-svc-redis", "devtool-svc-container", "devtool-svc-rabbit", "devtool-svc-kafka"]; /// Tập con của `ALLOWED_SERVICES` được đóng gói SẴN cùng app (có dòng tương @@ -147,8 +152,25 @@ impl ServiceResponse { } } +/// Tên hợp lệ cho một sidecar NGOÀI `ALLOWED_SERVICES`: `devtool-svc-` + kebab-case, +/// không có dấu phân cách đường dẫn hay thứ gì khác có thể thoát khỏi thư mục dịch vụ. +pub fn is_valid_service_name(bin: &str) -> bool { + const PREFIX: &str = "devtool-svc-"; + let Some(rest) = bin.strip_prefix(PREFIX) else { return false }; + bin.len() <= 64 + && !rest.is_empty() + && !rest.starts_with('-') + && !rest.ends_with('-') + && !rest.contains("--") + && rest.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + /// Lý do một request bị từ chối TRƯỚC khi chạm tới tiến trình con. Tách khỏi /// phần I/O để kiểm thử được mà không cần spawn gì cả. +/// +/// Chỉ lọc những gì có thể quyết định mà KHÔNG cần hỏi ai: sai protocol, hoặc +/// một cái tên không thể là sidecar. Một tên hợp lệ ngoài `ALLOWED_SERVICES` +/// KHÔNG bị từ chối ở đây — `ensure_trusted` hỏi người dùng khi sắp spawn. pub fn reject_reason(request: &ServiceRequest) -> Option { if request.protocol != SERVICE_PROTOCOL { return Some(format!( @@ -156,9 +178,10 @@ pub fn reject_reason(request: &ServiceRequest) -> Option { request.protocol )); } - if !ALLOWED_SERVICES.contains(&request.bin.as_str()) { + if !ALLOWED_SERVICES.contains(&request.bin.as_str()) && !is_valid_service_name(&request.bin) { return Some(format!( - "dịch vụ \"{}\" không nằm trong allowlist của host", + "dịch vụ \"{}\" không nằm trong allowlist của host và không phải tên sidecar hợp lệ \ + (cần dạng devtool-svc-)", request.bin )); } @@ -201,6 +224,7 @@ struct RunningSidecar { #[derive(Default, Clone)] pub struct ServiceRegistry { running: Arc>>>, + pub trust: Arc, } /// Phần "tìm cạnh file thực thi" của `sidecar_path` — hành vi gốc, giữ @@ -254,16 +278,23 @@ fn resolve_sidecar_path( /// xuống hành vi CŨ (tìm cạnh `current_exe()`, đúng quy ước với /// `mcp_bridge::mcp_sidecar_path`, nên không cần thêm tauri-plugin-shell hay /// mở quyền chạy tiến trình ở tầng capability). -fn sidecar_path(app: &AppHandle, bin: &str) -> Result { - // `extensions_dir` tự tạo thư mục nếu chưa có — lỗi ở đây (app_data - // không đọc được) không nên chặn hẳn fallback cạnh-exe, nên chỉ bỏ qua - // (`.ok()`) thay vì `?`. +/// Hai nơi một sidecar có thể nằm: index của bản tải-về (nếu đọc được) và thư +/// mục cạnh file thực thi. Dùng chung cho `sidecar_path` và `ensure_trusted`. +fn sidecar_dirs(app: &AppHandle) -> Result<(Option, std::path::PathBuf), String> { let index_dir = crate::artifact_installer::extensions_dir(app).ok(); let exe = std::env::current_exe().map_err(|e| e.to_string())?; let beside_dir = exe .parent() .ok_or("Không xác định được thư mục cài đặt của app")? .to_path_buf(); + Ok((index_dir, beside_dir)) +} + +fn sidecar_path(app: &AppHandle, bin: &str) -> Result { + // `extensions_dir` tự tạo thư mục nếu chưa có — lỗi ở đây (app_data + // không đọc được) không nên chặn hẳn fallback cạnh-exe, nên `sidecar_dirs` + // chỉ bỏ qua (`.ok()`) thay vì `?`. + let (index_dir, beside_dir) = sidecar_dirs(app)?; resolve_sidecar_path(index_dir.as_deref(), &beside_dir, bin) } @@ -382,7 +413,86 @@ fn service_data_dir(app_data_dir: &std::path::Path, bin: &str) -> std::path::Pat app_data_dir.join("service-data").join(bin) } +/// Hộp thoại native hỏi người dùng. Trả lời đi qua `oneshot`, nên không chặn luồng +/// nào trong lúc chờ. Webview không có đường nào để trả lời thay. +async fn ask_user(app: &AppHandle, dialog: crate::service_trust::ConsentDialog) -> bool { + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + let (tx, rx) = oneshot::channel(); + app.dialog() + .message(dialog.message) + .title(dialog.title) + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom(dialog.allow, dialog.deny)) + .show(move |allowed| { + let _ = tx.send(allowed); + }); + rx.await.unwrap_or(false) +} + +/// Ngôn ngữ người dùng đã chọn trong app, cho hộp thoại xin phép — đọc từ +/// `app-settings.json` trong `app_data` (cùng khoá `LocaleContext.tsx` ghi). +fn ui_locale_at(app_data: Option<&std::path::Path>) -> crate::service_trust::Locale { + app_data + .and_then(|d| std::fs::read_to_string(d.join("app-settings.json")).ok()) + .map(|raw| crate::service_trust::locale_from_settings(&raw)) + .unwrap_or(crate::service_trust::Locale::En) +} + +/// Phần QUYẾT ĐỊNH của `ensure_trusted`, không cần `AppHandle`: mọi thứ lấy từ +/// app (thư mục, home) và cách hỏi người dùng đều là tham số, nên test được trọn +/// đường đi — sidecar có sẵn, hỏi đúng một lần, từ chối, thiếu file, ngôn ngữ. +async fn ensure_trusted_with( + trust: &crate::service_trust::ServiceTrust, + bin: &str, + index_dir: Option<&std::path::Path>, + beside_dir: &std::path::Path, + app_data: Option<&std::path::Path>, + home: Option<&str>, + ask: F, +) -> Result<(), String> +where + F: FnOnce(crate::service_trust::ConsentDialog) -> Fut, + Fut: std::future::Future, +{ + if ALLOWED_SERVICES.contains(&bin) { + return Ok(()); + } + let path = resolve_sidecar_path(index_dir, beside_dir, bin)?; + let bytes = tokio::fs::read(&path) + .await + .map_err(|e| format!("Không đọc được \"{}\": {e}", path.display()))?; + let sha256 = crate::artifact_installer::sha256_hex(&bytes); + let source = index_dir.and_then(|d| crate::artifact_installer::installed_service_source_url_at(d, bin)); + let dialog = crate::service_trust::consent_dialog(ui_locale_at(app_data), bin, &path, &sha256, source.as_deref(), home); + trust.ensure(bin, &sha256, || ask(dialog)).await +} + +/// Sidecar của DevTool chạy không hỏi; mọi sidecar khác cần người dùng đồng ý +/// cho ĐÚNG file sắp chạy (mã băm lúc này, không phải lúc cài). +async fn ensure_trusted(app: &AppHandle, registry: &ServiceRegistry, bin: &str) -> Result<(), String> { + let (index_dir, beside_dir) = sidecar_dirs(app)?; + let app_data = app.path().app_data_dir().ok(); + let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok(); + ensure_trusted_with( + ®istry.trust, + bin, + index_dir.as_deref(), + &beside_dir, + app_data.as_deref(), + home.as_deref(), + |dialog| ask_user(app, dialog), + ) + .await +} + async fn get_or_spawn(app: &AppHandle, registry: &ServiceRegistry, bin: &str) -> Result, String> { + // Đường nhanh: đang chạy rồi thì khỏi hỏi lại và KHÔNG giữ khoá trong lúc + // một hộp thoại có thể mở hàng phút — các sidecar khác vẫn phải gọi được. + if let Some(running) = registry.running.lock().await.get(bin) { + return Ok(running.clone()); + } + ensure_trusted(app, registry, bin).await?; + let mut map = registry.running.lock().await; if let Some(running) = map.get(bin) { return Ok(running.clone()); @@ -578,15 +688,126 @@ mod tests { } #[test] - fn tu_choi_binary_ngoai_allowlist() { + fn tu_choi_ten_khong_the_la_sidecar() { // Điểm mấu chốt của ranh giới tin cậy: frontend nêu tên nào cũng được, - // chỉ tên trong danh sách của Rust mới chạy. - let reason = reject_reason(&request("/bin/sh", SERVICE_PROTOCOL)).expect("phải bị từ chối"); - assert!(reason.contains("allowlist"), "{reason}"); + // nhưng một cái tên có thể thoát khỏi thư mục dịch vụ thì không bao giờ tới + // được bước hỏi người dùng, chứ đừng nói tới bước chạy. + for bad in ["/bin/sh", "sh", "devtool-svc-", "devtool-svc-../x", "devtool-svc-a/b", "devtool-svc-A", "devtool-svc--x", "devtool-svc-x-"] { + let reason = reject_reason(&request(bad, SERVICE_PROTOCOL)).unwrap_or_else(|| panic!("{bad} phải bị từ chối")); + assert!(reason.contains("allowlist"), "{reason}"); + } + } + + #[test] + fn ten_hop_le_ngoai_allowlist_di_tiep_toi_buoc_hoi_nguoi_dung() { + // Không còn bị từ chối cứng: quyết định thuộc về người dùng (service_trust). + assert!(reject_reason(&request("devtool-svc-service-list-automation", SERVICE_PROTOCOL)).is_none()); + assert!(!ALLOWED_SERVICES.contains(&"devtool-svc-service-list-automation")); + assert!(is_valid_service_name("devtool-svc-redis")); + } + + // ── ensure_trusted_with: the consent decision, end to end, without an app ── + + fn fake_installed_sidecar(bin: &str, contents: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) { + let root = temp_dir("trust"); + let bin_path = root.join(bin); + std::fs::write(&bin_path, contents).unwrap(); + let extensions = root.join("extensions"); + std::fs::create_dir_all(&extensions).unwrap(); + write_fake_service_index(&extensions, bin, &bin_path); + (root, extensions) + } - let reason = reject_reason(&request("devtool-svc-chua-ton-tai", SERVICE_PROTOCOL)) - .expect("phải bị từ chối"); - assert!(reason.contains("allowlist"), "{reason}"); + fn memory_trust() -> crate::service_trust::ServiceTrust { + crate::service_trust::ServiceTrust::new(Box::new(crate::service_trust::MemoryBackend::default())) + } + + #[tokio::test] + async fn sidecar_dong_goi_san_khong_bao_gio_hoi() { + let trust = memory_trust(); + // Not even a file is needed: a bundled name never reaches the question. + let nowhere = std::path::Path::new("/nonexistent"); + let r = ensure_trusted_with(&trust, "devtool-svc-echo", None, nowhere, None, None, |_| async { + panic!("must not ask for a bundled sidecar") + }) + .await; + assert!(r.is_ok()); + } + + #[tokio::test] + async fn sidecar_cai_them_hoi_mot_lan_voi_hop_thoai_du_thong_tin() { + let bin = "devtool-svc-trusttest"; + let (root, extensions) = fake_installed_sidecar(bin, b"binary v1"); + let trust = memory_trust(); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + + for _ in 0..2 { + let seen = seen.clone(); + ensure_trusted_with(&trust, bin, Some(&extensions), &root, None, None, move |d| async move { + seen.lock().unwrap().push(d); + true + }) + .await + .unwrap(); + } + let dialogs = seen.lock().unwrap(); + assert_eq!(dialogs.len(), 1, "allowed once, then remembered"); + assert_eq!(dialogs[0].title, "Allow “trusttest” to run?"); + let sha = crate::artifact_installer::sha256_hex(b"binary v1"); + assert!(dialogs[0].message.contains(&sha[..12]), "{}", dialogs[0].message); + assert!(dialogs[0].message.contains("Source:"), "the install source comes from the index"); + } + + #[tokio::test] + async fn tu_choi_thi_khong_chay_va_doi_file_thi_hoi_lai() { + let bin = "devtool-svc-trusttest2"; + let (root, extensions) = fake_installed_sidecar(bin, b"v1"); + let trust = memory_trust(); + let denied = ensure_trusted_with(&trust, bin, Some(&extensions), &root, None, None, |_| async { false }).await; + assert!(denied.unwrap_err().contains("từ chối")); + + ensure_trusted_with(&trust, bin, Some(&extensions), &root, None, None, |_| async { true }).await.unwrap(); + // Same name, new bytes: the earlier answer does not carry over. + std::fs::write(root.join(bin), b"v2").unwrap(); + let asked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let a = asked.clone(); + ensure_trusted_with(&trust, bin, Some(&extensions), &root, None, None, move |_| async move { + a.store(true, std::sync::atomic::Ordering::SeqCst); + true + }) + .await + .unwrap(); + assert!(asked.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[tokio::test] + async fn khong_tim_thay_sidecar_thi_bao_loi_khong_hoi() { + let trust = memory_trust(); + let empty = temp_dir("trust-missing"); + let r = ensure_trusted_with(&trust, "devtool-svc-khong-co", None, &empty, None, None, |_| async { + panic!("nothing to ask about") + }) + .await; + assert!(r.is_err()); + } + + #[tokio::test] + async fn hop_thoai_theo_ngon_ngu_trong_app() { + let bin = "devtool-svc-trusttest3"; + let (root, extensions) = fake_installed_sidecar(bin, b"x"); + std::fs::write(root.join("app-settings.json"), r#"{"devtool-locale":"vi"}"#).unwrap(); + let trust = memory_trust(); + let title = std::sync::Arc::new(std::sync::Mutex::new(String::new())); + let t = title.clone(); + ensure_trusted_with(&trust, bin, Some(&extensions), &root, Some(&root), None, move |d| async move { + *t.lock().unwrap() = d.title; + true + }) + .await + .unwrap(); + assert_eq!(*title.lock().unwrap(), "Cho phép “trusttest3” chạy?"); + assert_eq!(ui_locale_at(None), crate::service_trust::Locale::En); + assert_eq!(ui_locale_at(Some(&temp_dir("no-settings"))), crate::service_trust::Locale::En); } #[test] diff --git a/src-tauri/src/service_trust.rs b/src-tauri/src/service_trust.rs new file mode 100644 index 00000000..d2f0aeaf --- /dev/null +++ b/src-tauri/src/service_trust.rs @@ -0,0 +1,564 @@ +// Sự đồng ý của người dùng để chạy một sidecar KHÔNG nằm trong +// `ALLOWED_SERVICES` (xem service_host.rs). +// +// Vì sao không chỉ thêm tên vào danh sách khi build app: mỗi plugin mới kèm +// sidecar sẽ buộc phải phát hành lại app. Vì sao cũng không để manifest tự khai +// được chạy: manifest do webview đọc — thứ ta đang muốn giới hạn. Nên quyết +// định được trao cho NGƯỜI DÙNG, qua một hộp thoại native do Rust bật; webview +// chỉ kích hoạt được câu hỏi, không trả lời thay được. +// +// ── Lưu ở đâu, và vì sao KHÔNG phải một file trong app_data ────────────────── +// +// Capability mặc định cấp cho webview `fs:allow-write-file` trên +// `fs:scope-appdata-recursive`, nên MỌI thứ nằm trong app_data — kể cả một file +// "đã tin cậy" hay `extensions/index.json` — webview tự ghi được, tức là tự cấp +// quyền được. Quyết định vì thế nằm trong keychain của hệ điều hành, chỗ webview +// không với tới (cùng nơi `secrets_vault.rs` cất khoá). Nơi nào không có +// keychain thì quyết định chỉ sống trong phiên chạy và app hỏi lại lần sau — +// thất bại theo hướng AN TOÀN (hỏi thừa), khác với vault ghi khoá ra file. +// +// ── Gắn với NỘI DUNG, không chỉ với tên ────────────────────────────────────── +// +// Mỗi mục là (bin, sha256 của file sẽ chạy). Binary bị thay hoặc cập nhật sang +// bản khác thì mã băm đổi và app hỏi lại; tên cũ không mang quyền sang nội dung +// mới. + +use std::future::Future; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex as AsyncMutex; + +const KEYRING_SERVICE: &str = "devtool"; +const KEYRING_USER: &str = "trusted-services"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TrustEntry { + pub bin: String, + pub sha256: String, +} + +/// Nơi bền vững giữ các quyết định. Tách thành trait để test không đụng +/// keychain thật. +pub trait TrustBackend: Send + Sync { + fn load(&self) -> Result, String>; + fn save(&self, entries: &[TrustEntry]) -> Result<(), String>; +} + +pub struct KeyringBackend; + +impl TrustBackend for KeyringBackend { + fn load(&self) -> Result, String> { + let entry = + keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(|e| e.to_string())?; + match entry.get_password() { + Ok(json) => Ok(serde_json::from_str(&json).unwrap_or_default()), + Err(keyring::Error::NoEntry) => Ok(Vec::new()), + Err(e) => Err(e.to_string()), + } + } + + fn save(&self, entries: &[TrustEntry]) -> Result<(), String> { + let entry = + keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(|e| e.to_string())?; + let json = serde_json::to_string(entries).map_err(|e| e.to_string())?; + entry.set_password(&json).map_err(|e| e.to_string()) + } +} + +/// In-memory backend for tests elsewhere in the crate (the real one is the keychain). +#[cfg(test)] +#[derive(Default)] +pub(crate) struct MemoryBackend(Mutex>); + +#[cfg(test)] +impl TrustBackend for MemoryBackend { + fn load(&self) -> Result, String> { + Ok(self.0.lock().unwrap().clone()) + } + fn save(&self, entries: &[TrustEntry]) -> Result<(), String> { + *self.0.lock().unwrap() = entries.to_vec(); + Ok(()) + } +} + +pub struct ServiceTrust { + backend: Box, + /// `None` cho tới lần đọc đầu tiên. Luôn là nguồn sự thật trong phiên, kể cả + /// khi `backend.save` thất bại. + entries: Mutex>>, + /// Chỉ cho MỘT câu hỏi chạy tại một thời điểm — hai lời gọi đồng thời tới + /// cùng sidecar chưa tin cậy không được bật hai hộp thoại. + gate: AsyncMutex<()>, +} + +impl Default for ServiceTrust { + fn default() -> Self { + Self::new(Box::new(KeyringBackend)) + } +} + +impl ServiceTrust { + pub fn new(backend: Box) -> Self { + Self { + backend, + entries: Mutex::new(None), + gate: AsyncMutex::new(()), + } + } + + fn with_entries(&self, f: impl FnOnce(&mut Vec) -> R) -> R { + let mut guard = self.entries.lock().unwrap(); + // Không đọc được keychain → coi như chưa tin ai (an toàn), không lỗi. + let entries = guard.get_or_insert_with(|| self.backend.load().unwrap_or_default()); + f(entries) + } + + pub fn is_trusted(&self, bin: &str, sha256: &str) -> bool { + self.with_entries(|e| e.iter().any(|t| t.bin == bin && t.sha256 == sha256)) + } + + /// Ghi nhận một quyết định cho phép. Mỗi `bin` giữ đúng một mã băm (bản mới + /// thay bản cũ). Ghi keychain là cố gắng tối đa: thất bại thì quyết định vẫn + /// có hiệu lực trong phiên này. + fn grant(&self, bin: &str, sha256: &str) { + let snapshot = self.with_entries(|e| { + e.retain(|t| t.bin != bin); + e.push(TrustEntry { + bin: bin.to_string(), + sha256: sha256.to_string(), + }); + e.clone() + }); + if let Err(e) = self.backend.save(&snapshot) { + eprintln!("service_trust: không lưu được quyết định vào keychain ({e}); chỉ có hiệu lực trong phiên này"); + } + } + + /// Thu hồi — gọi khi gỡ cài đặt. Chỉ GIẢM quyền nên an toàn để mọi nơi gọi. + pub fn revoke(&self, bin: &str) { + let snapshot = self.with_entries(|e| { + e.retain(|t| t.bin != bin); + e.clone() + }); + let _ = self.backend.save(&snapshot); + } + + /// Đảm bảo `(bin, sha256)` đã được tin cậy, hỏi người dùng nếu chưa. `ask` + /// chỉ chạy khi thật sự cần hỏi. + pub async fn ensure(&self, bin: &str, sha256: &str, ask: F) -> Result<(), String> + where + F: FnOnce() -> Fut, + Fut: Future, + { + if self.is_trusted(bin, sha256) { + return Ok(()); + } + let _gate = self.gate.lock().await; + // Người đến trước có thể đã được cho phép trong lúc ta chờ cổng. + if self.is_trusted(bin, sha256) { + return Ok(()); + } + if ask().await { + self.grant(bin, sha256); + Ok(()) + } else { + Err(format!("Bạn đã từ chối cho chạy chương trình \"{bin}\".")) + } + } +} + +/// Ngôn ngữ của hộp thoại — theo ngôn ngữ người dùng chọn trong app (khoá +/// `devtool-locale` của `app-settings.json`, cùng khoá `LocaleContext.tsx` ghi). +/// Thiếu hoặc lạ thì tiếng Anh. File nằm trong app_data nên webview ghi được, +/// nhưng nó chỉ đổi được NGÔN NGỮ của câu hỏi, không đổi được câu trả lời. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Locale { + Vi, + En, +} + +pub fn locale_from_settings(app_settings_json: &str) -> Locale { + let v: serde_json::Value = serde_json::from_str(app_settings_json).unwrap_or_default(); + match v.get("devtool-locale").and_then(|l| l.as_str()) { + Some("vi") => Locale::Vi, + _ => Locale::En, + } +} + +/// Hộp thoại native: tiêu đề, nội dung, nút đồng ý, nút từ chối. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConsentDialog { + pub title: String, + pub message: String, + pub allow: String, + pub deny: String, +} + +/// Cách gọi thư mục home theo quy ước của từng hệ điều hành. +const HOME_MARK: &str = if cfg!(windows) { "%USERPROFILE%" } else { "~" }; + +/// Thư mục home thay bằng `~` (macOS, Linux) hoặc `%USERPROFILE%` (Windows) — +/// ngắn hơn, và không lộ tên tài khoản khi người dùng chụp màn hình gửi đi. +fn shorten_home(path: &str, home: Option<&str>, mark: &str) -> String { + match home.filter(|h| !h.is_empty()) { + // `get` rather than slicing: a non-ASCII account name ("Thông") must not panic + // when the cut falls inside a character of a path that does not match. + Some(h) + if path + .get(..h.len()) + .is_some_and(|p| p.eq_ignore_ascii_case(h)) => + { + format!("{mark}{}", &path[h.len()..]) + } + _ => path.to_string(), + } +} + +/// Thư mục chứa file, rút gọn: với bản cài qua URL là `~/…/services//`. +/// Làm việc trên chuỗi, chấp nhận cả `/` lẫn `\`, và giữ đúng dấu phân cách của +/// đường dẫn — để kết quả giống nhau dù đường dẫn đến từ macOS, Linux hay Windows. +fn short_location(path: &str, home: Option<&str>, mark: &str) -> String { + let sep = if path.contains('\\') && !path.contains('/') { + '\\' + } else { + '/' + }; + let dir = match path.rfind(['/', '\\']) { + Some(i) => &path[..i], + None => path, + }; + let dir = shorten_home(dir, home, mark); + let parts: Vec<&str> = dir.split(['/', '\\']).collect(); + match parts.iter().position(|p| *p == "services") { + Some(i) if i > 1 => format!("{mark}{sep}…{sep}{}", parts[i..].join(&sep.to_string())), + _ => dir, + } +} + +/// Đủ để đối chiếu bằng mắt mà không phải đọc 64 ký tự. +fn short_hash(sha256: &str) -> String { + if sha256.len() <= 20 { + return sha256.to_string(); + } + format!("{}…{}", &sha256[..12], &sha256[sha256.len() - 6..]) +} + +/// Nguồn không có `https://` và query; dài thì giữ host và tên file cuối. +fn short_source(url: &str) -> String { + let bare = url.split(['?', '#']).next().unwrap_or(url); + let bare = bare + .strip_prefix("https://") + .or_else(|| bare.strip_prefix("http://")) + .unwrap_or(bare); + if bare.chars().count() <= 60 { + return bare.to_string(); + } + let host = bare.split('/').next().unwrap_or(bare); + let last = bare.rsplit('/').next().unwrap_or(""); + format!("{host}/…/{last}") +} + +/// Nội dung hộp thoại xin phép. Theo quy ước hộp thoại quyền của macOS: tiêu đề +/// là một câu hỏi có tên chương trình; thân là một câu giải thích, ba dòng nhận +/// diện (ở đâu, mã băm, từ đâu) và một câu nhắc; nút nói rõ hành động. +pub fn consent_dialog( + locale: Locale, + bin: &str, + path: &std::path::Path, + sha256: &str, + source: Option<&str>, + home: Option<&str>, +) -> ConsentDialog { + let name = bin.strip_prefix("devtool-svc-").unwrap_or(bin); + let location = short_location(&path.to_string_lossy(), home, HOME_MARK); + let hash = short_hash(sha256); + let source = source.map(short_source); + match locale { + Locale::Vi => { + let mut message = format!( + "Một plugin muốn chạy chương trình này trên máy bạn, với toàn quyền của tài khoản bạn.\n\n\ + Thư mục: {location}\nSHA-256: {hash}" + ); + if let Some(src) = &source { + message.push_str(&format!("\nNguồn: {src}")); + } + message.push_str("\n\nChỉ cho phép nếu bạn tin nguồn này. Nếu chương trình thay đổi, DevTool sẽ hỏi lại."); + ConsentDialog { + title: format!("Cho phép “{name}” chạy?"), + message, + allow: "Cho phép".into(), + deny: "Không cho phép".into(), + } + } + Locale::En => { + let mut message = format!( + "A plugin wants to run this program on your computer, with your full user permissions.\n\n\ + Folder: {location}\nSHA-256: {hash}" + ); + if let Some(src) = &source { + message.push_str(&format!("\nSource: {src}")); + } + message.push_str("\n\nAllow it only if you trust its source. DevTool will ask again if the program changes."); + ConsentDialog { + title: format!("Allow “{name}” to run?"), + message, + allow: "Allow".into(), + deny: "Don’t Allow".into(), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + #[derive(Default, Clone)] + struct Mem(Arc>>); + impl TrustBackend for Mem { + fn load(&self) -> Result, String> { + Ok(self.0.lock().unwrap().clone()) + } + fn save(&self, entries: &[TrustEntry]) -> Result<(), String> { + *self.0.lock().unwrap() = entries.to_vec(); + Ok(()) + } + } + + struct Broken; + impl TrustBackend for Broken { + fn load(&self) -> Result, String> { + Err("no keychain".into()) + } + fn save(&self, _: &[TrustEntry]) -> Result<(), String> { + Err("no keychain".into()) + } + } + + #[tokio::test] + async fn hoi_mot_lan_roi_nho_ca_qua_lan_khoi_dong_lai() { + let backend = Mem::default(); + let asked = Arc::new(AtomicUsize::new(0)); + + let trust = ServiceTrust::new(Box::new(backend.clone())); + let a = asked.clone(); + trust + .ensure("devtool-svc-x", "aa", || async move { + a.fetch_add(1, Ordering::SeqCst); + true + }) + .await + .unwrap(); + let a = asked.clone(); + trust + .ensure("devtool-svc-x", "aa", || async move { + a.fetch_add(1, Ordering::SeqCst); + true + }) + .await + .unwrap(); + assert_eq!(asked.load(Ordering::SeqCst), 1); + + // "Khởi động lại": một ServiceTrust mới đọc lại từ backend. + let restarted = ServiceTrust::new(Box::new(backend)); + assert!(restarted.is_trusted("devtool-svc-x", "aa")); + } + + #[tokio::test] + async fn tu_choi_thi_loi_va_khong_ghi_nho() { + let trust = ServiceTrust::new(Box::new(Mem::default())); + let err = trust + .ensure("devtool-svc-x", "aa", || async { false }) + .await + .unwrap_err(); + assert!(err.contains("từ chối"), "{err}"); + assert!(!trust.is_trusted("devtool-svc-x", "aa")); + } + + #[tokio::test] + async fn doi_noi_dung_binary_thi_hoi_lai() { + let trust = ServiceTrust::new(Box::new(Mem::default())); + trust + .ensure("devtool-svc-x", "aa", || async { true }) + .await + .unwrap(); + // Cùng tên, mã băm khác → quyền cũ không áp dụng. + assert!(!trust.is_trusted("devtool-svc-x", "bb")); + let err = trust + .ensure("devtool-svc-x", "bb", || async { false }) + .await + .unwrap_err(); + assert!(err.contains("từ chối")); + // Và cho phép bản mới thì bản cũ không còn được tin. + trust + .ensure("devtool-svc-x", "bb", || async { true }) + .await + .unwrap(); + assert!(!trust.is_trusted("devtool-svc-x", "aa")); + } + + #[tokio::test] + async fn khong_co_keychain_thi_van_chay_trong_phien_va_hoi_lai_lan_sau() { + let trust = ServiceTrust::new(Box::new(Broken)); + trust + .ensure("devtool-svc-x", "aa", || async { true }) + .await + .unwrap(); + assert!(trust.is_trusted("devtool-svc-x", "aa")); + // Phiên mới (không đọc được gì từ backend hỏng) → chưa tin ai. + let next = ServiceTrust::new(Box::new(Broken)); + assert!(!next.is_trusted("devtool-svc-x", "aa")); + } + + #[tokio::test] + async fn hai_loi_goi_dong_thoi_chi_bat_mot_hop_thoai() { + let trust = Arc::new(ServiceTrust::new(Box::new(Mem::default()))); + let asked = Arc::new(AtomicUsize::new(0)); + let mut tasks = Vec::new(); + for _ in 0..5 { + let (t, a) = (trust.clone(), asked.clone()); + tasks.push(tokio::spawn(async move { + t.ensure("devtool-svc-x", "aa", || async move { + a.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + true + }) + .await + })); + } + for t in tasks { + t.await.unwrap().unwrap(); + } + assert_eq!(asked.load(Ordering::SeqCst), 1); + } + + #[test] + fn thu_hoi_xoa_quyen() { + let backend = Mem::default(); + let trust = ServiceTrust::new(Box::new(backend.clone())); + trust.grant("devtool-svc-x", "aa"); + trust.revoke("devtool-svc-x"); + assert!(!trust.is_trusted("devtool-svc-x", "aa")); + assert!(backend.load().unwrap().is_empty()); + } + + #[test] + fn hop_thoai_gon_va_du_thong_tin_de_quyet_dinh() { + let path = std::path::Path::new( + "/Users/an/Library/Application Support/com.x/services/devtool-svc-service-list-automation/0.1.0/devtool-svc-service-list-automation", + ); + let sha = "2009009a8a4529e8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa4529e8"; + let src = "https://github.com/DianaSensei/mm-service-list-automation/releases/download/service-list-automation-v0.1.0/service-list-automation-service.manifest.json?x=1"; + let d = consent_dialog( + Locale::Vi, + "devtool-svc-service-list-automation", + path, + sha, + Some(src), + Some("/Users/an"), + ); + assert_eq!(d.title, "Cho phép “service-list-automation” chạy?"); + assert_eq!( + (d.allow.as_str(), d.deny.as_str()), + ("Cho phép", "Không cho phép") + ); + assert!( + d.message + .contains("Thư mục: ~/…/services/devtool-svc-service-list-automation/0.1.0"), + "{}", + d.message + ); + assert!( + d.message.contains("SHA-256: 2009009a8a45…4529e8"), + "{}", + d.message + ); + assert!( + d.message + .contains("Nguồn: github.com/…/service-list-automation-service.manifest.json"), + "{}", + d.message + ); + assert!(!d.message.contains("/Users/an"), "home must not appear"); + assert!(d.message.contains("toàn quyền")); + + let en = consent_dialog(Locale::En, "devtool-svc-x", path, sha, None, None); + assert_eq!(en.title, "Allow “x” to run?"); + assert!(!en.message.contains("Source:")); + assert_eq!(en.deny, "Don’t Allow"); + } + + #[test] + fn duong_dan_rut_gon_dung_cho_ca_ba_he_dieu_hanh() { + let bin = "devtool-svc-x"; + // macOS + assert_eq!( + short_location( + &format!("/Users/an/Library/Application Support/com.x/services/{bin}/0.1.0/{bin}"), + Some("/Users/an"), + "~" + ), + format!("~/…/services/{bin}/0.1.0") + ); + // Linux + assert_eq!( + short_location( + &format!("/home/an/.local/share/com.x/services/{bin}/0.1.0/{bin}"), + Some("/home/an"), + "~" + ), + format!("~/…/services/{bin}/0.1.0") + ); + // Windows: backslashes kept, home written the Windows way, drive letter case ignored + assert_eq!( + short_location( + &format!(r"C:\Users\An\AppData\Roaming\com.x\services\{bin}\0.1.0\{bin}.exe"), + Some(r"c:\users\an"), + "%USERPROFILE%" + ), + format!(r"%USERPROFILE%\…\services\{bin}\0.1.0") + ); + // Not an installed sidecar (a dev build beside the exe): the folder, home shortened. + assert_eq!( + short_location( + "/Users/an/dev/target/debug/devtool-svc-x", + Some("/Users/an"), + "~" + ), + "~/dev/target/debug" + ); + } + + #[test] + fn ngon_ngu_theo_lua_chon_trong_app() { + assert_eq!( + locale_from_settings(r#"{"devtool-locale":"vi"}"#), + Locale::Vi + ); + assert_eq!( + locale_from_settings(r#"{"devtool-locale":"en"}"#), + Locale::En + ); + assert_eq!(locale_from_settings("{}"), Locale::En); + assert_eq!(locale_from_settings("hỏng"), Locale::En); + } + + #[test] + fn rut_gon_khong_lam_hong_gia_tri_ngan() { + assert_eq!(short_hash("abc"), "abc"); + assert_eq!(short_source("https://h/m.json"), "h/m.json"); + assert_eq!(shorten_home("/opt/x", Some("/Users/a"), "~"), "/opt/x"); + // Non-ASCII account names: matching and non-matching paths, no panic. + assert_eq!( + shorten_home("/Users/Thông/x", Some("/Users/Thông"), "~"), + "~/x" + ); + assert_eq!( + shorten_home("/Users/ăn/x", Some("/Users/a"), "~"), + "/Users/ăn/x" + ); + } +}