From 4f43fe4886bebd78580097151ae95f16327acce9 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sun, 25 Oct 2026 09:02:13 +0800 Subject: [PATCH 1/7] feat(windows): unify plugin parity delivery and fixture coverage --- ...6-10-22-windows-declarative-plugin-host.md | 10 +- ...2026-10-25-plugin-fixture-coverage-gate.md | 82 ++ ...-25-windows-declarative-build-execution.md | 206 +++++ .github/workflows/ci-macos.yml | 29 + docs/development/windows-plugin-acceptance.md | 111 +++ .../windows-plugin-build-implementation.md | 143 ++++ .../windows-plugin-fixture-coverage.md | 125 +++ .../PluginSignatureVectorTests.swift | 180 ++++ scripts/check-plugin-fixture-coverage.mjs | 318 +++++++ .../check-plugin-fixture-coverage.test.mjs | 404 +++++++++ scripts/plugin-fixture-coverage.json | 547 ++++++++++++ shared/contracts/plugins/v1/README.md | 1 + .../v1/fixtures/signature-vectors.json | 40 + .../features/declarative-plugin-contract.json | 7 +- windows/tauri/src-tauri/Cargo.lock | 79 ++ windows/tauri/src-tauri/Cargo.toml | 1 + .../src-tauri/src/plugin_host/install.rs | 359 ++++++-- .../src/plugin_host/install_tests.rs | 367 ++++++-- .../tauri/src-tauri/src/plugin_host/launch.rs | 33 +- .../tauri/src-tauri/src/plugin_host/mod.rs | 14 + .../src-tauri/src/plugin_host/run_plans.rs | 6 +- .../tauri/src-tauri/src/plugin_host/scan.rs | 43 +- .../src-tauri/src/plugin_host/signature.rs | 798 ++++++++++++++++++ .../tauri/src-tauri/src/plugin_host/store.rs | 145 +++- .../tauri/src-tauri/src/plugin_host/tests.rs | 317 ++++++- 25 files changed, 4179 insertions(+), 186 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-10-25-plugin-fixture-coverage-gate.md create mode 100644 .agents/notes/proposed/architecture/2026-10-25-windows-declarative-build-execution.md create mode 100644 docs/development/windows-plugin-acceptance.md create mode 100644 docs/development/windows-plugin-build-implementation.md create mode 100644 docs/development/windows-plugin-fixture-coverage.md create mode 100644 macos/Tests/LitheTests/PluginSignatureVectorTests.swift create mode 100644 scripts/check-plugin-fixture-coverage.mjs create mode 100644 scripts/check-plugin-fixture-coverage.test.mjs create mode 100644 scripts/plugin-fixture-coverage.json create mode 100644 shared/contracts/plugins/v1/fixtures/signature-vectors.json create mode 100644 windows/tauri/src-tauri/src/plugin_host/signature.rs diff --git a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md index 14cca53000..203ccf98be 100644 --- a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md +++ b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md @@ -31,7 +31,8 @@ Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模 - 安装根为 `app_local_data_dir()/plugins///`,`installation.json` 记录版本与启用状态;记录缺失或损坏的包隔离展示,绝不静默加载。 - bundled 包从 `resource_dir()/OfficialPlugins` 只读加载,多版本目录取最高版本;运行时不写安装目录与资源目录,遵守发行基线只读边界。 - 每个插件的语言服务器状态目录在 `app_local_data_dir()/plugin-state//`,由宿主注入启动计划请求。 -- 信任模型 v0:官方 ID 防冒用由 Core 校验兜底;bundled 包随安装器分发;磁盘导入(已落地)与市场下载(M3+)一律走 `plugins.marketplace.installReview` 全量审批(权限 + 完整程序清单),审批界面明示"未认证发布者"。publisher Ed25519 验签明确延后,这是 v0 的已知缺口,Windows 验签方案(Authenticode 还是包内 Ed25519)单独立项评估。 +- 信任模型 v1(Windows 插件对齐路线图 M1,见 `docs/development/windows-plugin-parity-plan.md`):发布者 Ed25519 验签已接入 `plugin_host/signature.rs`,与 macOS `PluginPackageSignature` 字节兼容(canonical payload、UTF-8 base64url token、按原始路径排序、每文件 SHA-256 与 contentDigest),采用成熟的 `ed25519-dalek`,固定官方公钥 `lithe-official-plugins-v2`;生产私钥不进入仓库。官方 ID 的信任策略由宿主固定(`php/java-support=sameTeamAsHost`、`go-support=publisherPackage`),覆盖包自声明;`sameTeamAsHost` 只信任宿主安装资源内的 bundled 包,磁盘/市场包不能凭目录名获得该身份。签名文档存在即必须验证,坏签名不会被忽略。磁盘导入与市场下载(M2+)仍走 `plugins.marketplace.installReview` 全量审批(权限 + 完整程序清单),审批界面明示"未认证发布者"。 +- 安装记录 schema 2 是安装事务的一部分:`origin`、marketplace/disk provenance(全包内容摘要与批准程序的 canonical JSON)、上一版本的 provenance 都随版本发布一起写入;安装确认、staging 发布前、启动扫描、运行/测试计划入口与回滚都会重新校验完整文件集合和摘要。schema 1 旧记录没有 provenance,扫描直接隔离(`plugin.trust.provenanceMissing`)并要求重新安装,绝不凭 `enabled` 或清单摘要补造发布者身份和已批准字节。 - 磁盘导入的私有快照、staging 重验、内容摘要复查与回滚语义照抄 macOS 侧规则(见声明式语言插件 Note 的磁盘安装节),Windows 不另造审查语义。 ### 启动接线(M1 落地形状) @@ -83,14 +84,14 @@ WebView2 的 NavigationStarting 可以阻止外站文档提交,但实测外站 ### 实施切分 -M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁盘安装链路 + 管理界面 v1 + 死链处置(已落地);M3 声明式设置 + 运行/测试计划 + PHP 契约包(已落地,Worker 版保留见上文);M4 Web 工具窗口(已落地,桥 v0 只含 state/openFile/主题,build.* 与 functions.* 等 Windows 构建系统能力接入后开放)。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 +M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁盘安装链路 + 管理界面 v1 + 死链处置(已落地);M3 声明式设置 + 运行/测试计划 + PHP 契约包(已落地,Worker 版保留见上文);M4 Web 工具窗口(已落地,桥 v0 只含 state/openFile/主题,build.* 与 functions.* 等 Windows 构建系统能力接入后开放)。路线图 M1(发布者签名 + 全包完整性 + 安装记录事务,`signature.rs` 与 schema 2 安装记录)已落地;本 Note 早期的 M1–M4 编号是宿主 PR 的内部切分,与路线图的 M0–M10 不是同一序列。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 ## 考虑过的备选方案 - 把 Worker 扩展模型整体改造成契约宿主:契约没有主题、AI provider 这些概念,改造成本高且无收益,不采用。 - 在 Tauri 侧另写一套清单校验:违反"校验只在 Core 实现一次",共享 fixtures 无法复用,双端语义必然漂移,不采用。 - 把 Worker RPC 桥接成 litheFunctions:两者协议语义不同,会同时存在两套函数协议,等有真实插件需求时再按[插件函数契约](2026-10-20-plugin-function-contract.md)接入。 -- 信任模型一步到位 publisher 签名:Windows 验签载体需要单独评估,v0 以 bundled 加全量审批兜底,签名延后是显式记录的缺口而不是遗漏。 +- 信任模型一步到位 publisher 签名:v0 曾以 bundled 加全量审批兜底,签名延后是当时显式记录的缺口;路线图 M1 已选择与 macOS 相同的包内 Ed25519 文档签名(而非 Authenticode),因为它跨平台字节兼容、验证在安装事务内完成,且不依赖系统证书存储。 - 在 `lsp_start` 一类原生命令里拉起插件服务器:现有 LSP 会话全部由 Core 统一管理(会话、取消、事件泵都在 Core),前端只解析可执行路径与参数;另建原生拉起路径会分裂会话所有权,不采用。 ## 后果 @@ -111,6 +112,7 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 - 网页隔离与上下文回归:`web.rs` 覆盖同包和外站 URL 的导航/桥接共用检查、真实路径与内部/外部目录链接;Windows 用无需提权的 junction,Unix 用目录符号链接。`plugin-web-appearance.test.ts` 执行原生宿主桥脚本,验证语言、主题、根变量、主题事件与外站不暴露桥;前端文件测试验证原生真实目标传递与拒绝传播。 - 版本探测回归:`runtime_probe_collects_output_and_rejects_overflow`、`runtime_probe_reclaims_inherited_pipes_after_parent_exit` 使用当前测试二进制作为固定夹具;后者让父进程退出、后代保留管道,断言调用有界结束且后代已退出。测试环境变量仅附加到子进程,不改测试运行器的全局环境。运行 WindowsRust 与 Frontend 计时 harness,检查 `.artifacts/test-stability/index.html`;真实 WebView2 验证还要由本机 HTTP 计数器确认外站 GET 为零,同时检查导航、弹窗/下载、动态主题、包文件和发行目录哈希不变;单凭页面 URL 没变不能证明请求未外发。 +- 路线图 M1 信任链:`plugin_host/signature.rs` 用 RFC 8032 测试密钥锚定 Ed25519 原语;共享公开向量 `shared/contracts/plugins/v1/fixtures/signature-vectors.json`(含非 ASCII 文件名、canonical payload 字节、确定性签名与 contentDigest)由 `shared_fixture_verifies_end_to_end` 断言,Swift 端 `PluginSignatureVectorTests` 消费同一文件(macOS 实机运行待 CI)。`install_tests` 覆盖 staged 字节绑定批准摘要、同版本替换拒绝改动字节、staging 失败保留现行版本、回滚候选按其自身 provenance 验证;`tests.rs` 覆盖重启扫描隔离篡改/增删文件、官方 ID 拒绝无签名社区安装、社区包中存在的签名文档必须验证。`declarative_plugins_confirm_disk_install` 与回滚保持 #1424 的逐窗口停止屏障,失败时旧版本与旧记录保持可用。 - M4 Web 工具窗口:`plugin_host/web.rs` 测试覆盖包路径规范化(`..`、编码分隔符、空段拒绝)、CSP 内容断言(default-src none、无 wasm-unsafe-eval、frame-ancestors none)、桥脚本冻结与作用域、状态键隔离;web-panel fixture 随 `ui:[1,2]` 声明提升转为被宿主接受(能力门控测试同步)。桥 v0 的 build.*/functions.* 返回 `plugin.web.unknownMethod`,等构建系统能力接入。 @@ -142,6 +144,8 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 - `windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx`(设置编辑) - `windows/tauri/src/features/plugins/declarative-plugin-run.ts`(契约插件运行入口) - `windows/tauri/src-tauri/src/plugin_host/web.rs`(Web 工具窗口 scheme、桥与状态) +- `windows/tauri/src-tauri/src/plugin_host/signature.rs`(发布者验签、contentDigest、官方 ID 固定信任策略;依赖 `ed25519-dalek`) +- `shared/contracts/plugins/v1/fixtures/signature-vectors.json`(双端共享签名测试向量;macOS 消费方为 `macos/Tests/LitheTests/PluginSignatureVectorTests.swift`) - `Plugins/win/Official/PhpSupportContract/`(契约版 PHP bundled 包) - `windows/tauri/src/features/plugins/declarative-plugins-section.tsx`(设置内管理页) - `windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts`(契约插件回落来源) diff --git a/.agents/notes/implemented/testing/2026-10-25-plugin-fixture-coverage-gate.md b/.agents/notes/implemented/testing/2026-10-25-plugin-fixture-coverage-gate.md new file mode 100644 index 0000000000..174641691e --- /dev/null +++ b/.agents/notes/implemented/testing/2026-10-25-plugin-fixture-coverage-gate.md @@ -0,0 +1,82 @@ +# Agent 笔记:插件 fixture 覆盖门禁 + +状态:已实现 + +## 先说结论 + +`shared/contracts/plugins/v1/fixtures` 的覆盖检测器(`scripts/check-plugin-fixture-coverage.mjs` + `plugin-fixture-coverage.json` + 单测)已接入 `ci-macos.yml` 的 `changes` job——该 job 在 ubuntu runner 上对每个 pull request(含 Draft)运行,不新增 workflow、不占 macOS/Windows runner。任何人新增 fixture 而不在清单登记,真实 PR 检查会以 `unknown-fixture` 失败。门禁的防伪造立场是"证据与诚实分类",不是"永远保持 partial":允许全部 fixture 真实 wired 后零缺口通过,但没有证据的 wired、没有阶段归属的缺口一律拒绝。 + +## 问题 + +- 覆盖清单(M0 交付)靠显式登记维护。若只在本地运行检测器,"新增 fixture 忘了登记"只会安静漏过,`unknown-fixture` 不会让任何 PR 失败,清单会重新漂移。 +- 第一版真实仓库守护测试断言 `knownGaps > 0`:把 2026-10-25 当天的真实缺口数量固化成了永久约束。未来某天缺口真实补齐时这个断言必然失败,反而诱导后人删掉守护测试。 +- 缺口阶段(gapStage)的注释说 M0–M10,正则却排除 M0,语义与说明不一致。 + +## 决策 + +### CI 入口复用现有 Linux lane,不旁建 workflow + +`ci-macos.yml` 的 `changes` job 已有一个"scripts 测试 + JUnit/HTML 计时报告 + artifact 上传"的成熟 lane(Sparkle baseline 模式):`node --test` 双 reporter(spec 到 stdout、junit 到 `.artifacts/test-stability/.junit.xml`),再用 `parse-junit-cases.mjs` + `writeTestReportArtifacts` 生成逐项计时的 HTML/JUnit 报告,最后由同一 job 的 upload 步骤上传整个 `.artifacts/test-stability/`。插件 fixture 门禁镜像同一模式插入三步:检测器 CLI(真正的清单 vs 目录比较)、单测(`--test-timeout=15000` 本地期限)、计时报告生成。该 job 在 ubuntu 上对所有 PR 运行(含 Draft),因此 #1476 这类新增 fixture 的 PR 在合入前就会撞上 `unknown-fixture`。 + +门禁与测试的接线由测试自身守护:`check-plugin-fixture-coverage.test.mjs` 里有一条 CI 接线守卫测试,读取 `ci-macos.yml` 断言两个入口(CLI 步骤、测试文件)与期限/报告参数存在——workflow 改名或移除调用时,测试先红。 + +### 防伪造 = 诚实分类,不是永久 partial + +- 正例:一个全部 wired 且证据文件真实存在的清单,检测器必须通过且 `knownGaps = 0`(有专门单测锁住这条路)。未来覆盖补齐时门禁自然放行。 +- 负例:wired 却没有 Windows 证据、known gap 缺阶段或说明、证据引用文件不存在,全部拒绝(既有单测覆盖)。 +- 真实仓库守护测试只断言"清单与目录一致 + 每个非 wired 条目带合法阶段与说明",不再断言缺口数量。 + +### gapStage 语义:M0–M10 全部合法 + +路线图 M0 的退出条件把基线问题路由进聚焦修复 PR,所以"由 M0 拥有的缺口"是合法归属;M11 及以后不存在。正则为 `^M(10|[0-9])$`,注释与实现一致,并有单测同时覆盖 M0 接受与 M11 拒绝。 + +### M1 交接(#1476) + +`fixtures/signature-vectors.json` 在 #1476 中新增;其 PR 合入 preview 前(或同时)必须在清单登记,否则撞 `unknown-fixture`。最小登记条目(检测器已预置 `signature-vectors` 类别): + +```json +{ + "path": "signature-vectors.json", + "category": "signature-vectors", + "outcome": "success", + "coreEvidence": [], + "windowsEvidence": ["windows/tauri/src-tauri/src/plugin_host/signature.rs:663 shared_fixture_verifies_end_to_end"], + "windowsStatus": "<按 #1476 实际证据填 wired 或 validated-only>", + "gapStage": null, + "gapDetail": null +} +``` + +该类别是唯一允许 `coreEvidence` 为空的类别:签名向量由双端宿主各自的签名测试消费(Windows `signature.rs`、macOS `PluginSignatureVectorTests.swift` 的 RFC 8032 锚点、确定性签名、内容摘要、签名文档回读四组 @Test),没有 Core 校验面;豁免只对 `signature-vectors` 生效,其他类别仍必须有 Core 侧证据(有单测锁定)。`backend.plugin.json` 已由本 PR 随 preview 同步(`83017afa`/8471b998)登记为 `unwired` + `followup`——`followup` 是为"冻结路线图(M0–M10)之后落地、按计划 §6 单列后续阶段"的新能力保留的缺口阶段,是 M 编号之外的唯一合法值。登记条目的最终行号以 #1476 合并时的代码为准。合并顺序优先 #1474(门禁)先合、#1476 随后带登记合入;若 #1476 先合,它必须自带登记条目,否则其 PR 门禁失败。 + +## 考虑过的备选方案 + +- 新建独立 `ci-fixture-coverage.yml`:多一个 workflow、多一份维护与触发面,且与"优先复用现有门禁"的仓库惯例冲突,不采用。 +- 只在本地可选运行检测器:登记纪律依赖人的记忆,已知会漂移,不采用。 +- 保留 `knownGaps > 0` 作为防伪造断言:把"今天有缺口"固化成"永远有缺口",未来真实补齐反而红,防伪造应针对"没有证据的 wired",不采用。 +- 在 `verify-shared-contracts.sh`(Ruby 内联)里实现枚举比较:该脚本是契约语义校验(schema/拒绝码),把 Node 已实现的检测器复制一份 Ruby 只会分叉逻辑,不采用。 + +## 后果 + +- 收益:新增 fixture 未登记会直接打红 PR(含 Draft);单测有 15 秒本地期限与逐项计时报告;覆盖补齐路径(全 wired)有正例保证畅通。 +- 代价:`ci-macos.yml` 的 `changes` job 多两步(约几秒);维护清单成为改 fixture 的同 PR 义务;workflow 与测试之间由守卫测试双向绑定,改 CI 入口措辞时守卫测试需要同步。 + +## 验证 + +```bash +node scripts/check-plugin-fixture-coverage.mjs +node --test --test-timeout=15000 scripts/check-plugin-fixture-coverage.test.mjs +node --test --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/plugin-fixture-coverage.junit.xml scripts/check-plugin-fixture-coverage.test.mjs +./.agents/skills/write-stable-tests/scripts/verify-test-stability.ps1 # 或 .sh +node scripts/verify-agent-notes.mjs +``` + +CI 等效路径:`ci-macos.yml` changes job(ubuntu)的 "Check plugin fixture coverage manifest" / "Run plugin fixture coverage tests" / "Generate plugin fixture coverage timing report" 三步,artifact 随既有 upload 步骤上传。 + +## 适用范围 + +- `scripts/check-plugin-fixture-coverage.mjs`、`scripts/plugin-fixture-coverage.json`、`scripts/check-plugin-fixture-coverage.test.mjs` +- `.github/workflows/ci-macos.yml` 的 changes job 中 "plugin fixture coverage" 三步 +- `docs/development/windows-plugin-fixture-coverage.md`(审计快照与维护规则) + +不覆盖:M1 拥有的签名/安装信任链 Note(#1476)、共享契约 fixtures 本体、平台功能矩阵。 diff --git a/.agents/notes/proposed/architecture/2026-10-25-windows-declarative-build-execution.md b/.agents/notes/proposed/architecture/2026-10-25-windows-declarative-build-execution.md new file mode 100644 index 0000000000..52fbafabde --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-25-windows-declarative-build-execution.md @@ -0,0 +1,206 @@ +# Agent 笔记:Windows 声明式构建执行模型与安全调度(M4 设计) + +状态:提议中 + +## 先说结论 + +Windows 要接入插件 API 3 的声明式构建系统(`buildSystems`,即插件用纯 JSON 描述"怎样认出工程、用哪个可执行文件、有哪些任务和测试"),方案是把全部构建协调放进 Rust 宿主的一个新构建会话登记表,原生界面和插件网页两个通道都调用这同一份事实;Core 的 14 个 `build.*` 命令语义只在 `lithe-core` 实现一次,Windows 经 `core_execute` 调用,不在宿主重写任何清单解释、argv 展开或输出解码。执行前统一走一条授权链:M1 信任闸门 → 生命周期隔离租约 → 窗口与工作区代次 → 操作 ID 与冲突检查 → Core 生成的计划校验。能力版本 `buildSystems:[1,2]` 与 `executableResolution:[1]` 只在对应行为完整验收后才声明,此前保持关闭。本设计依赖同一统一 PR 中 M1 的信任与安装记录接口;后续实现须等 M1 修复复核后推进,并在该 PR 内累计提交,不另建阶段 PR。 + +## 问题 + +正式路线图 [Windows 插件功能对齐 macOS](../../../../docs/development/windows-plugin-parity-plan.md) 的 M4 要求接入 `build.detect`、`invocationPlan`、`checkToolVersion`、`resolveOptions`、模型/任务/测试/依赖计划与解码、`syncFingerprint`、`languageImport`,以及 `buildSystems:2` 兼容输入与 `executableResolution:1` 的候选解析。当前基线 `1e78b59b`(#1424 合入后)的状态是: + +- 宿主 `host_capabilities` 只声明 `languages/languagesServers/serverProcess/settings/ui`(`windows/tauri/src-tauri/src/plugin_host/mod.rs:37`),Core 侧其实早已支持 `buildSystems [1,2]` 与 `executableResolution [1]`(`rust/lithe-core/src/plugins/capabilities.rs:55`),差的只是宿主接线。 +- 网页桥 v0 把 `build.instances`、`build.refresh`、`build.runTask`、`build.runTests`、`build.cancel`、`build.dependencies`、`build.setOptions` 一律拒绝为 `plugin.web.unknownMethod`(`windows/tauri/src-tauri/src/plugin_host/web.rs:787`),等待构建能力接入。 +- 现有 Maven 是宿主内置特例:`windows/tauri/src-tauri/src/maven.rs` 与 `windows/tauri/src/features/maven/` 按构建系统名字写死了配置、依赖输出与界面,社区插件无法复用;路线图要求它保留为回归基线,不在本轮删除。 +- 不处理会造成两个后果:一是 API 3 构建插件在 Windows 不可安装不可运行;二是若原生界面和插件网页各自实现一套构建状态,主窗口与插件 WebView 会对同一工作区得出两份互相矛盾的构建事实(路线图 §2.2 明确禁止)。 + +## 提案 + +### 总体形态:Rust 端唯一构建会话登记表 + +新增 Rust 模块 `windows/tauri/src-tauri/src/plugin_host/build.rs`(首批可拆 `build_registry.rs` 与 `build_resolver.rs` 两个文件),作为唯一构建操作入口。它持有按 `(宿主窗口 label, 工作区 ID, 插件 ID, buildSystemID, 实例根)` 索引的构建会话,每个会话记录:实例列表、模型、选项生效值、同步指纹、忙碌/过期标记,以及每个在途操作的唯一 operation ID、工作区代次快照与取消状态。 + +前端新增 `windows/tauri/src/features/build-systems/` 统一 TS service,只维护展示状态(树、任务分组、选项输入、忙碌与错误提示),通过 Tauri 命令调用 Rust。命令命名沿用现有 `declarative_plugins_*` 前缀模式(参照 `run_plans.rs:30` 的 `declarative_plugins_run_plan`):`declarative_plugins_build_instances / build_refresh / build_run_task / build_run_tests / build_cancel / build_dependencies / build_set_options`。注意 `windows/tauri/src/platform/tauri-core.ts:18` 的分发规则:点分共享 Core 操作走 `platform_invoke` 调度器且必须同步注册到 `main.rs`,所以这些命令一律按普通 Tauri 命令注册,不进入点分命名空间。 + +网页桥不新增第二套语义:`web.rs` 的 8 个 `build.*` 方法只做页面身份校验(复用 `web.rs:40` 的 `PageOwner`、`web.rs:80` 的 `with_page_owner` 代次检查、`web.rs:140` 的 `owned_label`),然后转发到同一个登记表函数。页面拿不到通用 IPC,也不能指定回复目标;结果只回原页面。 + +选择 Rust 而不是 TS 承担协调,是路线图 §2.2 预留的既定选项:现有接口不足以支持"网页 → 主窗口 TS 模型"的定向请求/回复通道(web 桥没有回复路由),而 Rust 已拥有信任、生命周期与进程能力;TS 若维护第二份构建事实必然漂移。 + +### Core 命令接线清单(14 条全部可复用,宿主侧全部缺失) + +Core 命令注册在 `rust/lithe-core/src/build/commands.rs:91-246`,经 `plugin_host/scan.rs:190` 的 `core_execute` 调用(与现有 launch/run_plans 相同入口): + +| 命令 | Windows 接线 | 缺失接线 | +| --- | --- | --- | +| `build.detect` | bootstrap 扫描后 + 选项/插件变化后重新识别;static 与 hostIntegration 模型随识别返回 | 全部缺失 | +| `build.invocationPlan` | 解析器输入:候选顺序、环境变量白名单、版本探测参数 | 全部缺失 | +| `build.checkToolVersion` | 合并 stdout/stderr 交 Core 校验,复用 `launch.rs:430` 的有界探测执行器 | 全部缺失 | +| `build.resolveOptions` | 选项合并、`dropped` 处理、配置指纹 | 全部缺失 | +| `build.modelPlan` + `build.decodeModel` | static:识别时直读;command:用户点加载时执行模型命令、stdout 交 Core | 全部缺失 | +| `build.taskPlan` / `build.testPlan` | 工具窗口与节点菜单运行 | 全部缺失 | +| `build.dependencyPlan` + `build.decodeDependencies` | 依赖节点首次展开时加载 | 全部缺失 | +| `build.decodeDiagnostics` / `build.decodeTestResults` | 任务/测试输出解码,结果进 Build Output | 全部缺失 | +| `build.syncFingerprint` | 监听文件摘要,判断模型过期 | 全部缺失 | +| `build.languageImport` | Java 会话启动前归一化 Maven/Gradle 输入,转发给 LSP 启动配置 | 全部缺失 | + +Core 错误语义保持不动:清单/声明问题返回 `{status:"rejected"}` 稳定错误码,请求本身格式错误才是 `invalid_request`;`plugin.build.invalidOutput/invalidSelection` 等错误码直接透传到界面。 + +### 三种模型的执行路径 + +- `static`:识别时 Core 直接读项目文件(如 Cargo.toml),零进程,打开项目即可展示。永远可用,不需要同意流程。 +- `command`:模型命令是进程。用户点击"加载模型"或运行任务时,先 `build.invocationPlan` 拿候选,Windows 解析器(新 `build_resolver.rs`,对照 `macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift`)按候选顺序解析出可执行文件与受限环境,再启动进程:模型/依赖加载是一次性有界收集(stdout 单独捕获交 Core 解码、stderr 进 Build Output、每流上限、取消期限),复用 `launch.rs:430-468` 已验证的 `lithe_git_host::run` 执行器语义(Job Object 进程树、非阻塞管道、有界收集)。项目内 Wrapper(`projectFile` 候选)一律视为执行仓库代码,做版本探测和运行都按同一信任策略,不采信清单自报。 +- `hostIntegration`:目前只有官方 Maven。经 Core 的 Maven 集成解析 reactor/依赖树/Surefire 结果,宿主只负责按计划启动 `mvn`。现有 Windows Maven 专用流程(`maven.rs`、`features/maven/`、`main.rs:281-290` 注册的命令)原样保留作回归基线,与声明式路径并存,去重规则与 macOS 相同:声明式插件接管后实例从空状态开始,不沿用专用流程缓存。 +- `provider` 来源:M4 不接。`buildProviders` 能力继续关闭(宿主不声明,Core 在 validate 阶段就拒绝声明 Provider 的包),Provider 会话属 M5。`build.detect` 对 Provider 来源只报告不加载的语义由 Core 保证,宿主无须处理。 + +### executableResolution:1 与 buildSystems:2(冻结范围内,不是未来需求) + +- 候选五种来源已在 Core 定型(`rust/lithe-core/src/plugins/executables.rs:82-103`):`projectFile`(工程内 Wrapper)、`environment`(声明过的环境变量)、`command`(PATH)、`runtime`(java/node/go 等运行时)、`setting`(用户显式选择的全局文件偏好);`uses_resolution()`(`executables.rs:115`)定义了需要 `executableResolution:1` 能力的声明形态(setting 来源或 priority/insertBefore/insertAfter 排序元数据)。fixture `build-tool-path.plugin.json` 同时要求 `buildSystems:1 + settings:2 + executableResolution:1`,是本项的验收输入。 +- 解析语义照抄 macOS 侧 `macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift:5-29` 与构建契约 Note 的"统一的候选解析扩展"一节:数组顺序是默认优先级;`setting` 来源为空字符串时跳过该候选、非空但无效时是终止失败(报错并停止本次模型/依赖加载,不得静默回退 PATH 或让 Provider 自行发现);Runtime 候选把所选目录前置到候选自己的 PATH 再探测与执行,失败候选的 PATH 不传给下一个候选;Provider 的可选 tool 提示复用同一解析结果(M5 前不触发)。 +- `buildSystems:2` 的增量是清单里 `invocation.executableSetting` 旧适配器:Core 在 `rust/lithe-core/src/build/manifest.rs:1368-1373` 要求版本 2 且存在 effect 为 reload/nextRun 的可执行全局设置,否则拒绝。Windows 声明 `[1,2]` 即可同时接受两种清单;不要为了让旧包可装而声明成只有 1。 +- 用户显式选择的工具路径存进现有声明式设置存储(`windows/tauri/src-tauri/src/plugin_host/settings.rs:15` 的 `app_local_data_dir()/plugin-settings/.json`,项目覆盖在 `settings.rs:26`),不新建第二套工具偏好文件;Core 的 `resolveOptions` 与设置推送使用同一存储。 + +### tasks / tests / dependencies / languageImport / 运行入口 + +- 任务与测试:`build.taskPlan`/`testPlan` 生成精确 argv 后,走现有运行管线执行(`windows/tauri/src-tauri/src/run.rs:68` 的 `RunProcessManager`、`:675` 的 `run_resolve_launch`、`:200/265/308` 的按窗口进程清点与停止)。构建任务因此天然获得按窗口停止、进程树清理与关闭兜底,不必另写一套运行器;宿主只登记构建归属(插件、buildSystemID、实例根、operation ID),不把页面提交的 argv 交给通用 `run_start_process`。 +- 依赖:`build.dependencyPlan`/`decodeDependencies` 在依赖节点首次展开时加载;Maven 依赖树输出的临时文件语义对照 macOS(宿主在插件状态目录分配输出文件,读完即删),不写安装目录。 +- `languageImport`:`build.languageImport` 按返回的 `kind` 归一化为 `mavenContext`/`javaGradle`,在 Java 会话启动前转发给 LSP 启动配置(Windows 侧接线点在 Java 启动就绪链路,`windows/tauri/src/platform/java-launch-readiness.ts` 一线)。被动发现不执行 Wrapper、不做版本探测;找不到工具沿用 JDT 默认设置,不阻塞启动。 +- 运行入口:构建模型 `run` 声明与旧 detector、Worker 动作、`executionProviders` 的去重优先规则在 M4 只登记归属(声明式构建来源的入口标注插件与实例),完整运行配置对接按路线图单独排期;macOS 同一路径也缺,列为共享缺口,不另造契约未定义的行为。 + +### 多构建系统项目的选择 + +一个工作区同时识别出多个实例(不同构建系统或同系统多实例根)时:每个已启用插件的每个 `buildSystemID` 固定一个工具窗口(登记进现有工具窗口贡献注册表,ID 模式 `.build-systems:`,对照 macOS `BuildSystemToolWindowRegistration.swift`);窗口内用实例根选择器切换,候选与生效值必须来自所选实例,不允许取首实例代表全部。插件已启用但未检测到工程时窗口显示空状态,入口不消失;停用/卸载插件后窗口与命令同步撤销(复用 `declarative-plugin-ui.ts` 的撤销模式)。 + +### 安全调度:每个操作的授权链 + +所有构建执行(原生与网页、加载与运行)在启动前依次通过: + +1. **M1 信任闸门**:包当前处于已安装、已接受、已启用状态,版本字节与安装记录一致。M4 只定义调用点与失败码,闸门实现与持久化 schema 属 M1(见下节接口清单),M4 不并行定义第二套安装记录。 +2. **生命周期隔离**:进程存续期间持有 `lifecycle.rs:37` 的 `NativeLease`(`acquire(plugin_id)`),停用/替换/卸载时 `lifecycle.rs:27` 的 `blocked` 立即拒绝新操作,等待既有使用者退出后再动包。 +3. **调用者身份**:原生调用校验 `(宿主窗口 label, 工作区 ID)` 及当前工作区代次;网页调用经 `with_page_owner` 校验页面归属、页面代次与工作区代次(`web.rs:80-98`),页面必须属于同一插件。 +4. **操作登记**:唯一 operation ID;同槽并发是忙碌冲突(返回可诊断错误,不排队覆盖);跨窗口互不可见、互不可取消。 +5. **参数与 cwd 校验**:cwd 是工作区内解析后的实例根(拒绝 junction/reparse point 逃逸,沿用 `launch.rs` 包路径闸门语义);只执行 Core 计划产出的可执行文件与 argv;环境变量只放行声明过的 `environmentInputs`、运行时管理的 PATH 前置与既有 `LITHE__PATH` 约定,不做任意透传。 +6. **最终启动复检**:启动前再查一次槽位仍有效、代次未变、插件未被 `blocked`、扫描缓存中包版本未变。任一失败按过期处理,不启动。 + +取消与迟到结果的语义照抄 macOS 已验证的规则(构建契约 Note §10"取消后谁能写回"):完成回调同时校验工作区代次、槽位 operation ID 与取消状态,只有仍持有槽的任务能写回模型/依赖/诊断;取消后仍在排空的输出在追加前检查归属;每个旧任务负责清理自己的临时输出文件。停止(用户点停止、窗口关闭、工作区切换、插件停用)必须等待子进程树(Job Object 兜底)与运行管线登记项全部结束,只从列表移除不算完成。`processJobTree` 与共享 Core 生命周期全部保留,不新增绕过 Job Object 的执行路径。 + +### 网页桥 build.* 语义 + +`web.rs` 现有拒绝列表(`:787`)替换为转发实现:`build.instances/setOptions/dependencies` 是读与选项写,走登记表;`build.refresh/runTask/runTests` 是执行,与原生同一条授权链,网页不获得任何额外信任;`build.cancel` 只能取消本页面发起(或本页面归属窗口发起)的操作。等待同意也占用并发名额,取消先于许可到达时操作不得再启动。页面关闭、同包导航、工作区切换、插件停用时,该页面的在途构建操作全部取消并丢弃迟到回复(复用现有页面撤销机制)。 + +### 正确做法 + +- 插件网页选择自己已加载模型中的任务 → 桥校验页面归属 → 登记表校验代次与同意 → `build.taskPlan` 生成 argv → 运行管线启动 → 输出回该页面与 Build Output。 +- 用户把 Maven settings 路径选到不存在文件 → `executableResolution` 的 setting 来源报终止失败,模型/依赖加载停止并给出可诊断错误,不回退 PATH。 +- 停用插件 → `blocked` 拒绝新操作 → 已注册 `NativeLease` 的构建进程等待退出 → 重新扫描 → 工具窗口撤销。 + +### 不要这样做 + +- 在 `build.rs`、resolver 或 TS service 里写 `if buildSystemID == "maven"`、`== "gradle"`;生态知识只在 Core。 +- 让网页把 `executable`、任意工作区路径或 argv 交给通用进程启动命令,即使页面已通过 CSP 与来源检查。 +- 在能力未完整验收前把 `buildSystems` 加进 `host_capabilities`,或把 `build-system-plugins` 矩阵改成 implemented;partial 与 pending 如实记录。 +- 为构建另写一套安装记录、信任状态或 `host_capabilities` 数据源。 + +## M1 提供的输入接口(M4 只消费,不定义) + +M1(PR #1476,head `3408f781`,主审中)独占签名、安装记录 schema、`plugin_host` 的 store/scan/install/launch 生产路径、Cargo 依赖、`declarative-plugin-contract` 矩阵与 Windows 宿主 owning Note。M4 依赖以下接口,实现 PR 必须等 M1 合入后基于最终形态接线: + +| M1 接口 | M4 用途 | 消费方式 | +| --- | --- | --- | +| 扫描缓存中的包状态(现有 `CatalogEntry.status/enabled`,M1 扩展验证记录) | 执行前信任闸门:已安装、已接受、已启用、当前版本字节一致 | 只读查询,不缓存跨操作副本 | +| 发布者验签与全包摘要结果(M1 安装事务产物) | 闸门判断"通过 M1 验证";web 桥与原生同源 | 只读 | +| 安装审查的已批准程序/权限记录(含 command 模型命令、任务/测试命令、打包运行时) | `projectFile`/`command` 执行的同意来源 | 只读;不通过审查的程序不执行 | +| `lifecycle.rs` 隔离 API(`blocked/acquire/NativeLease`) | 构建进程注册为原生使用者 | 直接调用,等待语义以 M1 最终版为准 | +| `host_capabilities`(`mod.rs:37`)与 `declarative-plugin-contract` 矩阵 | M4 最后一 PR 声明 `buildSystems:[1,2]`、`executableResolution:[1]` | 同一时间只允许一个 PR 修改;在 M1 矩阵 PR 之后串行 | + +签名与安装事务的任何并行变更都在 M1 的 PR 里进行;M4 出现"需要 M1 新增接口"的情况时,先在本 Note 的验收标准里补依赖记录,再由 M1 落地,不在 M4 PR 里改 store/install。 + +## 分批 PR 范围与验收 + +按路线图"模型/解析;任务测试/统一桥"切三批,每批一个聚焦 PR,都默认 Draft: + +**PR-A 只读检测与展示(static 线)** +- 新增 `plugin_host/build.rs`(登记表 + detect + static 模型 + `resolveOptions` + `syncFingerprint` 监听 + 选项持久化)与 `features/build-systems/` TS service、工具窗口注册、空状态。 +- 验收:build-static-file fixture 经真实 Core 识别并展示;选项修改经 Core 校验后持久化且 `dropped` 改存生效值;指纹变化后窗口显示"需要重新加载";重新打开工作区选项保留。能力保持关闭,用宿主测试直接带 `hostCapabilities` payload 调 Core 验证。 +- 有界测试:登记表并发用受控假执行器(无真实 sleep);resolver 暂不涉及。 + +**PR-B 进程执行与 Maven 对照** +- 新增 `build_resolver.rs`(五种候选来源解析、`checkToolVersion`、终止失败语义);command/hostIntegration 模型加载;task/test 经运行管线执行;依赖加载;`decodeDiagnostics/decodeTestResults` 进 Build Output;取消/期限/输出上限/进程树等待;`languageImport` 接 Java 启动链。 +- 验收:build-command-stdout、build-command-output-file、build-host-integration fixture 的宿主行为覆盖;Maven 专用流程回归不退化;取消加载与等待同意时切项目的场景通过;配置路径带空格、失效 setting 值为终止失败。 +- 有界测试:进程用固定夹具可执行文件与可控完成顺序;迟到输出与旧代次写回拒绝;Job Object 树结束断言进程不存在而非仅列表移除。 + +**PR-C executableResolution、统一桥与能力开放** +- `executableResolution:1` 完整语义 + `buildSystems:2` 的 `executableSetting` 接受;`web.rs` 8 个 `build.*` 方法经登记表实现;跨窗口、页面关闭、过期代次、权限变化(停用/更新/回滚/卸载期间)场景收口;`host_capabilities` 增加 `buildSystems:[1,2]`、`executableResolution:[1]`;矩阵与契约文档同 PR 更新(`declarative-plugin-contract` 归 M1,先协调再动)。 +- 验收:build-tool-path fixture 端到端(含 setting 显式无效终止失败);至少一个真实声明式构建插件完成保存、同步、模型、运行/测试、停止、重新打开;网页与原生走同一生产链(同一实例状态、同一忙碌冲突、同一取消);矩阵更新后 `verify-platform-feature-matrix-change` 通过。 +- 有界测试:桥的跨 owner/过期代次/取消竞态;能力门控测试随声明更新(参照 settings-v2 fixture 被接受的现有模式)。 + +## 场景覆盖要求(不允许只写 happy path) + +每个实现 PR 的测试与验收必须包含:执行失败(可执行文件缺失、非零退出、输出超限、解码失败);取消(用户取消、窗口关闭、工作区切换、插件停用)及取消后迟到输出不写入新状态;跨窗口互不干扰与各自的忙碌冲突;过期结果(代次变化后的迟到完成只标过期);权限变化(M1 停用/更新/回滚/卸载期间 `blocked`、包字节替换后旧操作不得重启);升级/卸载后工具窗口撤销、登记表清理、进程树确认结束。 + +## 考虑过的备选方案 + +### 构建协调放在主窗口 TS service,网页走定向请求/回复通道 + +最有吸引力的地方:与现有 React 功能模型分层一致,调试直观。不采用:web 桥当前没有可信任的定向回复路由(页面不能选择回复目标),需要新发明一套窗口间协议;而 Rust 已有信任、生命周期与进程能力,路线图 §2.2 明确允许这种情况下把协调器放 Rust。 + +### 每个 `build.*` 桥方法在 web.rs 独立实现授权与取消 + +最有吸引力的地方:改动局部、互不影响。不采用:会出现两套授权链和取消语义,最终启动复检很容易漏掉其中一套,这正是 M4 要消灭的分裂。 + +### 提前声明 buildSystems 能力,用真实包做端到端验证 + +最有吸引力的地方:真实包驱动开发,验收早。不采用:违反路线图"完整实现后声明相应版本";capability 一旦声明,市场就会列出仅部分可用的插件。分批期间用 fixture 驱动的宿主测试(带显式 `hostCapabilities` payload 调 Core),能力开放集中在 PR-C。 + +### 构建任务绕过运行管线,在 build.rs 自建执行器 + +最有吸引力的地方:参数可控,少一层归属映射。不采用:重复实现停止、进程树清理、按窗口清点与关闭兜底;运行管线已有 `WindowRunProcess` 与 Job Object 语义,构建任务复用它只登记归属即可。 + +### 保留现有 Maven 专用流程作为构建系统的展示前端 + +最有吸引力的地方:Maven 界面已存在,直接把声明式数据喂进去最快。不采用:`features/maven/` 的类型与界面按 Maven 语义写死,通用模型(多实例、通用选项、任意任务分组)塞不进去;它保留为独立回归基线,声明式界面按通用模型新建。 + +## 验收标准 + +- 14 个 `build.*` 命令在 Windows 宿主全部有真实接线,Core 语义零复制(评审对照 `commands.rs` 与 `build.rs` 调用点)。 +- 三种非 Provider 模型(static/command/hostIntegration)各有 fixture 级宿主行为验收;`build-tool-path` 覆盖 `executableResolution:1`,`executableSetting` 覆盖 `buildSystems:2`。 +- 原生与网页两通道经同一登记表,授权链六步齐全;本 Note 场景清单全部有测试或验收证据。 +- `build-system-plugins` 矩阵 Windows 侧在 PR-C 后如实更新(implemented + pending,验证完成前不得记 verified);`plugin-web-tool-windows` 的 build 方法部分同步;`declarative-plugin-contract` 由 M1 归属协调。 +- 本 Note 状态在 M4 三批 PR 全部合入并验收后,按 agent-notes 规范迁移为 implemented,并把"构建协调在 Rust"的决策并入 Windows 宿主 owning Note 或在其 owning Note 增补链接。 + +## 风险 + +- M1 主审中且有信任链/事务问题待修复:M4 的 PR-A/B 可以在独立分支准备代码与测试(不依赖新接口的部分),但接线 PR 与能力开放必须等 M1 合入后的最终形态,避免基于未合入实现假设。 +- 运行管线复用需要把构建归属映射进 `RunProcessManager` 的现有所有权模型(按窗口 + 源),若其停止语义与构建槽位冲突,需要在 PR-B 内聚焦调整,不外溢到无关运行功能。 +- WebView2 网页通道的取消时机依赖页面生命周期回调,跨窗口场景需要真实双窗口验收;单测只能覆盖状态机。 +- Maven 双路径并存期间,用户可见两个 Maven 入口(专用流程与声明式窗口),需在 PR-B 的界面文案与管理页分组中明确区分,遵循现有双模型分组模式。 + +## 验证 + +本轮是设计文档,仅运行: + +```bash +node scripts/verify-agent-notes.mjs +``` + +实现 PR 各自的验证范围(本 Note 只规定,不代跑): + +```powershell +cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml plugin_host +# 工作目录 windows/tauri +bun run test -- features/build-systems +./scripts/verify-windows-boundaries.ps1 +node scripts/generate-platform-feature-matrix.mjs --check +node scripts/verify-platform-feature-matrix-change.mjs +``` + +本轮未运行 Windows 构建或应用实例(与 M1 任务避免资源争抢),未修改任何产品代码、共享契约、矩阵或宿主模块;上述结论的源码定位均在基线 `1e78b59b` 核对。 + +## 适用范围 + +本 Note 为 M4 设计真源;下列文件为设计指向的落点(本轮全部未修改): + +- 拟新增:`windows/tauri/src-tauri/src/plugin_host/build.rs`、`build_registry.rs`、`build_resolver.rs`;`windows/tauri/src/features/build-systems/` +- 拟修改(M4 PR 内):`windows/tauri/src-tauri/src/plugin_host/mod.rs`(`host_capabilities`)、`web.rs`(build.* 转发)、`main.rs`(命令注册)、`plugin_host/settings.rs`(只读复用)、`run.rs`(归属登记) +- 参考实现:`macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift`、`macos/Sources/Lithe/Platform/MacOS/Build/`、`macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift`、`macos/Sources/Lithe/Views/Workbench/BuildSystemToolWindowRegistration.swift` +- 契约与依赖:`rust/lithe-core/src/build/commands.rs`、`rust/lithe-core/src/plugins/{capabilities,executables}.rs`、`rust/lithe-core/src/build/manifest.rs`、`shared/contracts/plugins/v1/`(fixtures `build-static-file`、`build-command-stdout`、`build-command-output-file`、`build-host-integration`、`build-tool-path`、`build-plans.json`)、[构建系统插件契约](../../implemented/architecture/2026-10-17-build-system-plugin-contract.md)、[Windows 声明式插件宿主](../../implemented/architecture/2026-10-22-windows-declarative-plugin-host.md)、[Windows 插件路线图](../../../../docs/development/windows-plugin-parity-plan.md) diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 48c4140218..71a9b442db 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -76,6 +76,35 @@ jobs: writeTestReportArtifacts(`${stem}.json`); NODE + # Declarative-plugin fixture coverage: the directory enumeration and + # the explicit classification list must match, so a fixture added + # without registration fails every pull request on this Linux lane. + - name: Check plugin fixture coverage manifest + timeout-minutes: 2 + run: node scripts/check-plugin-fixture-coverage.mjs + + - name: Run plugin fixture coverage tests + timeout-minutes: 2 + run: | + node --test --test-timeout=15000 \ + --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/plugin-fixture-coverage.junit.xml \ + scripts/check-plugin-fixture-coverage.test.mjs + + - name: Generate plugin fixture coverage timing report + if: always() + run: | + node --input-type=module <<'NODE' + import fs from 'node:fs'; + import { parseJUnitCases } from './.agents/skills/write-stable-tests/scripts/parse-junit-cases.mjs'; + import { writeTestReportArtifacts } from './.agents/skills/write-stable-tests/scripts/generate-test-report.mjs'; + const stem = '.artifacts/test-stability/plugin-fixture-coverage'; + const tests = parseJUnitCases(fs.readFileSync(`${stem}.junit.xml`, 'utf8')); + if (tests.length === 0) throw new Error('Missing plugin fixture coverage test results'); + fs.writeFileSync(`${stem}.json`, JSON.stringify({ runner: 'node', warnMs: 1000, maxMs: 15000, tests })); + writeTestReportArtifacts(`${stem}.json`); + NODE + - name: Upload Sparkle download timing report if: always() uses: actions/upload-artifact@v4 diff --git a/docs/development/windows-plugin-acceptance.md b/docs/development/windows-plugin-acceptance.md new file mode 100644 index 0000000000..dfcca5c908 --- /dev/null +++ b/docs/development/windows-plugin-acceptance.md @@ -0,0 +1,111 @@ +# Windows 插件真实包验收矩阵(M10 准备) + +状态:验收准备资料,不是验收记录。本文按 +[Windows 插件功能对齐计划](windows-plugin-parity-plan.md) M10 的要求,固定真实 Windows 插件包的身份、来源与逐项可复现验收步骤,并区分第一阶段(M1–M6)与第二阶段(M7–M9)出口。**本轮没有执行任何一次完整验收,也没有任何一行可以据此宣告 M10 完成。** + +数据核实:2026-10-24(首次)与 2026-10-25(能力声明/矩阵维度复核,并同步 preview `83017afa`),gh API(仓库元数据、release 资产、`Lithe-IDEA/Lithe-store/lithe-plugin-index.json`、`Lithe-IDEA/lithe-plugin-docs`)+ 主仓库基线 commit `1e78b59b6385fca6a881639b42048bf3bc587f4a` 的源码。索引与 release 会持续变化,每次执行验收前必须重新核实并把当时的 tag/版本/SHA-256 写进当次记录,不得沿用本文。 + +## 1. 通用验收流程(每个真实包逐项执行) + +以下链条来自路线图 M10 与 Windows 声明式插件宿主 Note 的既有边界,每个包按适用子流程裁剪,但裁剪必须在记录里写明。 + +| # | 步骤 | 通过标准 | 记录要求 | +| --- | --- | --- | --- | +| 1 | 前置固定 | 宿主构建 SHA、包 tag/commit、版本、下载 SHA-256、架构、构建配置(Release)全部登记 | 命令与输出 | +| 2 | 安装 | 市场下载按索引 SHA-256 核对(`plugin.archive.checksumMismatch` 拒绝篡改);磁盘导入走私有快照;staging→发布事务完成 | 安装来源字段与内容摘要 | +| 3 | 授权 | installReview 全量审批:权限 + 完整程序声明逐项可展开;社区包明示"未认证发布者";新增程序(newPrograms)高亮 | 审批界面截图 + 审批记录持久化确认 | +| 4 | 启用与重启 | 启用后重启应用,启动扫描结果一致;停用状态持久化;bundled 包仍为最高版本 | 扫描前后 catalog 对比 | +| 5 | 真实工作流 | 打开目标项目 → 语言服务启动/构建模型 → 运行、测试 → 取消一次在途操作;操作在真实 UI 触发,非仅命令调用 | 每步入口(菜单/按钮)、预期/实际 | +| 6 | 升级失败 | 更新时篡改摘要或中断下载:旧版本与旧安装记录保持可用,本次 staging 被清理,无"新目录配旧记录" | 失败注入方式与恢复证据 | +| 7 | 回滚 | 回滚到上一版本:先停止读用旧包的会话与页面,再换目录;回滚包按自身审批记录复验 | 页面关闭与进程停止证据 | +| 8 | 卸载 | 包目录、安装记录、所属页面、相关进程全部消失;无半删目录 | 卸载后目录与进程列表 | +| 9 | 进程树清理 | 每步结束后按包入口路径核对进程列表:语言服务器、Provider、构建子进程、辅助探测进程为零残留(Job Object 语义,非仅杀父 PID) | 进程快照(启动前/每步后) | +| 10 | 发行资源哈希不变 | 安装目录(含 `resource_dir()/OfficialPlugins` 等 bundled 资源)在验收前后的文件清单与哈希完全一致;运行时状态只出现在平台可写目录 | 前后哈希清单 diff | +| 11 | 证据归档 | 按路线图 §5.4:版本三元组、入口、复现步骤、预期/实际、测试命令与单测计时、哈希清单、进程清理、已知范围;截图先脱敏 | 存放位置与索引 | + +架构要求:`windows-x64` 与 `windows-arm64` 分别记录。没有 arm64 实机或可验证执行环境时保留 pending——**编译成功或 x64 仿真不替代 arm64 原生运行证据**。构建脚本已有 `aarch64-pc-windows-msvc` 目标标识(`scripts/build-windows.ps1`),但目前没有已发布的 arm64 工件与实机记录。 + +## 2. 真实包矩阵 + +"当前可得"列描述在本轮核实时点的可安装性。全部社区包当前只有 `darwin-*` 工件;**没有任何 windows-\* 工件存在**,这是第二列之外最大的统一 pending。 + +| 包 | 来源与版本(核实于 2026-10-24) | 许可 | Windows 入口(x64/arm64) | 外部 runtime | 当前可得 / 缺失 | 负责阶段 | +| --- | --- | --- | --- | --- | --- | --- | +| PhpSupportContract(bundled 契约包) | 主仓库 `Plugins/win/Official/PhpSupportContract`,版本 1.0.0,api2,`sameTeamAsHost`(基线 1e78b59b) | 仓库内 Lithe 资源;`intelephense.js` 为 65 字节占位入口 | runtime launch:node 启动 `language-server/bin/intelephense.js`;无 `${arch}` 路径,两架构同入口 | Node.js(宿主发现);`php` CLI(运行/测试 executable) | 已随宿主安装链验收(validate/launch/runPlan/testPlan/resolveSettings 端到端测试 `tests.rs:433`);真实 Intelephense 替换 pending——其许可禁止再分发(见 `2026-10-20-go-php-community-plugins` Note),Windows 分发方式需另行决定 | M10 第一阶段(完整实机链) | +| io.github.lithe-idea.php-support | [lithe-plugin-php](https://github.com/Lithe-IDEA/lithe-plugin-php) tag `v0.1.0`,api1 | Apache-2.0(仓库 license) | systemCommand `intelephense --stdio`;工具架构随用户安装 | Node.js(runtimeDependencies)+ 用户自装 intelephense(npm,许可禁止打包) | macOS 可装;**windows 工件 pending**;官方索引/商店均未列 windows 平台 | M2(市场安装链)→ M10 第一阶段 | +| io.github.lithe-idea.go-support | [lithe-plugin-go](https://github.com/Lithe-IDEA/lithe-plugin-go) tag `v0.1.0`,api1 | Apache-2.0 | systemCommand `gopls`;go 工具链架构随系统 | Go 工具链(runtimeDependencies,宿主发现) | macOS 可装;**windows 工件 pending** | M2 → M10 第一阶段 | +| io.github.lithe-idea.lua-support | [lithe-plugin-lua](https://github.com/Lithe-IDEA/lithe-plugin-lua) tag `v0.3.0`(另有 0.1.0/0.2.0),api1 | Apache-2.0 | packaged `bin/lithe-lua-language-server`——**架构相关二进制**,Windows 需按 x64/arm64 分别出工件 | 无(服务器自带) | macOS 可装(0.3.0 起分 darwin-arm64/darwin-x64 两个资产);**windows 工件 pending** | M2 → M10 第一阶段 | +| io.github.lithe-idea.gradle-build | [lithe-plugin-gradle](https://github.com/Lithe-IDEA/lithe-plugin-gradle) tag `v0.1.1`,api3,`requires {buildSystems:1}`,模型源 command | Apache-2.0 | 声明式构建工具窗口 + 命令计划;入口依赖构建工具(Gradle wrapper/gradle) | Gradle(项目 wrapper 或 PATH) | macOS 可装;Windows 当前 `apiUnsupported`(宿主未声明 buildSystems);**windows 工件 pending** | M4 开放能力 → M10 第一阶段 | +| io.github.lithe-idea.maven-build | [lithe-plugin-maven](https://github.com/Lithe-IDEA/lithe-plugin-maven) tag `v0.1.1`,api3,`requires {buildProviders:1, buildSystems:1, serverProcess:1}`,模型源 provider | Apache-2.0 | Provider 进程(自有 launch)+ Core litheBuild 协商 | JDK(provider 进程与 Maven 工具链) | macOS 可装:宿主能力表声明 `buildProviders:[1]`、`buildSystems:[1,2]`、`serverProcess:[1]`(`PluginTypes.swift:492-503`),Core `SUPPORTED_CAPABILITIES` 同表;矩阵 `build-provider-refresh-and-tool` 记 macOS implemented/验证 pending(`MacBuildProviderSessionsTests` 覆盖会话自动化)——implemented 不等于已验证;Windows `apiUnsupported`(能力未开放);**windows 工件 pending** | Windows 侧 M5 开放能力 → M10 第一阶段(Provider/函数线);macOS 侧验证对照随 M10 第一阶段 | +| io.github.lithe-idea.cargo-build | [lithe-plugin-cargo](https://github.com/Lithe-IDEA/lithe-plugin-cargo) tag `v0.1.1`,api3,`requires {buildProviders:1, buildSystems:2, serverProcess:1, settings:2}`,模型源 provider;0.1.0 为 `{buildSystems:1, buildProviders:1}` | Apache-2.0 | Provider 进程;cargo 工具链 | Rust/Cargo 工具链 | 0.1.1 的 `buildSystems:2` 由 Core(`capabilities.rs` `SUPPORTED_CAPABILITIES`)与 macOS 宿主能力表(`buildSystems:[1,2]`)共同支持,macOS 可装(矩阵 `cargo-build-plugin` 记 macOS implemented/验证 pending);Windows `apiUnsupported`(buildSystems/buildProviders 未声明);**windows 工件 pending** | Windows 侧 M4/M5 开放能力(路线图 M4 明确含 `buildSystems:2` 兼容输入解析)→ M10 第一阶段 | +| io.github.lithe-idea.svn-support | [lithe-plugin-svn](https://github.com/Lithe-IDEA/lithe-plugin-svn) tag `v0.1.0`,api3,`requires {vcsProviders:1, serverProcess:1}`,vcsTool svn | Apache-2.0 | VCS Provider 进程会话 | svn 命令行客户端(用户提供) | macOS 可装:宿主已声明 `vcsProviders:[1]`(`PluginTypes.swift:502`),Core 支持;矩阵 `vcs-subversion-and-plugin-providers` 记 macOS partial/验证 pending(`VcsProviderHostTests` 覆盖根识别、元数据监听与生命周期自动化;svn 解析与错误码由其插件仓库 CI 经 Core 桥接在 file:// 仓库验证);Windows `apiUnsupported`;**windows 工件 pending** | M9 → M10 第二阶段 | +| Java Support(dev.lithe.plugin.java-support) | macOS bundled 官方包;JDT LS 上游固定 [v1.61.0](https://github.com/eclipse-jdtls/eclipse.jdt.ls/tree/v1.61.0);包内含 `LICENSE-EPL-2.0.txt`(组装脚本 `assemble-package.sh` 随包携带) | JDT LS 及 Eclipse 扩展为 EPL-2.0(随包 LICENSE 文件);icons 目录 Apache-2.0 | Windows 无分发:路线图 M8 明确必须满足其整份 `requires`,不得复用 macOS ZIP(含 macOS 架构 JDK/runtime) | 打包 JDK(packagedRuntime,`${arch}` 路径) | macOS bundled 可用;**Windows 分发 pending(构建/打包决策未做)** | M8(生成器/分发)+ M10 第一阶段(语言链验收) | +| 官方可下载插件渠道 | `Plugins/marketplace/index.json` 与 `official-plugins.json` 均为空;发布管线 `release-official-plugins.yml` 存在(macos-26 runner 构建、tag 系列 ``) | 按未来包而定 | 官方索引当前无条目,无可验收的官方 Windows 包 | — | 渠道就绪但**无条目 → pending**;第一个官方包发布后按 §1 全链验收 | M2(索引/下载)→ M10 第一阶段 | +| 原生 UI 真实示例(ui:3) | 未选定外部示例仓库与版本 | 待选型 | native schema 树/列表/表单渲染 | 待选型 | **pending 选型**;路线图要求"固定版本的外部示例" | M7 → M10 第二阶段 | +| 项目生成器真实包(projectGenerators) | 未选定(`project-generator` fixture 仅覆盖 Core 语义;JavaSupport 模板为 macOS 分发) | 待选型 | 新建项目窗口 + files/command/archive 步骤 | 待选型 | **pending 选型** | M8 → M10 第二阶段 | + +补充说明: + +- 商店索引当前 8 个插件、13 个版本全部只列 `darwin-arm64`/`darwin-x64` 下载(gh 复核 2026-10-25,`lithe-plugin-index.json`)。Windows 可用性在这些包发布 windows-\* 工件并更新索引之前,一律按 `plugin.index.platformUnsupported` 展示——这是契约定义的诚实状态,不能通过放宽校验绕过。 +- 索引新增的 `io.github.lithe-idea.database 0.1.0` 声明 `requires {backend:1, serverProcess:1, ui:2}`:本审计基线 `1e78b59b` 的 Core 未定义 `backend` capability;preview 已在 `8471b998`(#1470 数据库插件线)新增 `backend:[1]` 且 macOS 宿主能力表已声明(`PluginTypes.swift:493`),Windows 宿主对 backend 零引用。本 PR 已同步 preview,`fixtures/backend.plugin.json` 已随同步登记进覆盖清单。该插件属于本轮 M0–M10 冻结范围之外的新能力,这里只记录事实,不列为验收项。 +- 宿主能力与 Core 支持版本的事实来源:`macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift:492-504`(`PluginManifest.hostCapabilities`:backend:[1]、buildProviders:[1]、buildSystems:[1,2]、executableResolution:[1]、executionProviders:[1]、projectGenerators:[1]、serverProcess:[1]、settings:[1,2]、ui:[1,2,3]、vcsProviders:[1]、languages:[1]、languageServers:[1,2])与 `rust/lithe-core/src/plugins/capabilities.rs`(`SUPPORTED_CAPABILITIES`)。注意 `shared/contracts/plugins/v1/README.md` 能力一节仍写 macOS 不声明 buildProviders/vcsProviders(preview 上也保留),已落后于代码,以代码与功能矩阵为准(过时描述已记录在 fixture 覆盖审计 §5)。 + +## 3. 分包类验收步骤(§1 通用链的裁剪点) + +### 3.1 语言插件(php-support / go-support / lua-support / Java Support) + +- 前置:目标 runtime 可被发现(Node/Go/JDK)或用户自装工具存在(intelephense/gopls);缺失时 LSP 状态显示缺少工具而不是崩溃。 +- 安装 → 审批(重点核对 systemCommand 程序声明的 `commands/arguments/validationArguments`)→ 启用 → 打开匹配扩展名的文件 → 语言服务器启动、补全/诊断可用 → 停用后进程退出、入口撤销 → 更新/回滚/卸载全链。 +- lua 的 packaged 二进制:额外核对拒绝包内 reparse point/路径逃逸、`${stateDir}` 状态目录不写包内。 +- Windows 特有:`LITHE_NODE_PATH`/`LITHE_GO_PATH` 导出与 PATH 前置在从开始菜单启动(非 shell)时生效。 + +### 3.2 构建插件(gradle-build / maven-build / cargo-build) + +- 前置:宿主能力声明已含 `buildSystems`/`buildProviders`(M4/M5 之后),市场侧展示可安装而不是不支持。 +- 检测(marker/nesting/priority)→ 工具窗口按声明注册 → 模型/依赖加载(command 或 provider)→ 选项修改与 `resolveOptions` → 任务/运行/测试执行与取消 → sync 文件变更触发过期提示。 +- maven/cargo 的 provider 模型:核对 litheBuild 协商失败可诊断、崩溃重启、`refresh` 语义;两个项目窗口互不串会话。 +- Windows 特有:Maven 原有专用流程不得退化;Worker/声明式的拥有者优先规则按计划 M0 审计结论执行。 + +### 3.3 Provider 函数与设置页(随 maven-build/cargo-build) + +- 网页 `functions.list/invoke/cancel` 走本插件 Provider;`settingActions` 按钮与网页同链路;`restart/immediate/reload/nextRun` 四种 effect 在真实进程上观察。 +- 关闭页面/项目、停用、更新时无残留调用与旧进度。 + +### 3.4 原生 UI 示例(M7 后) + +- 树/列表/表单/工具栏/右键动作渲染;中文输入、键盘导航、浅深主题、窄面板回退、状态恢复、并发动作;停用后入口与窗口撤销。 +- 更新 `declarative-native-plugin-ui` 矩阵前,上述全部为验收项而非演示项。 + +### 3.5 项目生成器(M8 后) + +- 欢迎页/菜单进入通用新建项目窗口 → 插件生成器字段与步骤 → 目标旁临时 staging → 成功后移动,失败/取消删除 → Git 初始化与 JDK 设置 → 打开后进入语言导入与构建识别。 +- 目标目录已存在或被并发创建时拒绝覆盖;失败无半成品;成功不写安装目录。 + +### 3.6 VCS Provider(M9 后) + +- 根识别 → 状态/提交/更新/回滚入口按协商能力显示 → 关闭项目窗口停止 Provider 会话 → 嵌套根归属最深 → 缺少 svn 客户端、凭据/网络失败按稳定码展示。 +- Git 主流程不回归是并行退出条件。 + +## 4. 阶段出口核对单 + +第一阶段出口(M1–M6 完成后执行): + +- [ ] 至少一个真实市场包完成 §1 全链(1–11)并归档证据。 +- [ ] 语言(php/go/lua 之一)、构建(gradle/maven 之一)、Provider+函数与设置页各有真实包行完成。 +- [ ] Windows bundled PhpSupportContract 用真实 Intelephense(或明确的分发决定)完成完整实机链。 +- [ ] windows-x64 每行有记录;windows-arm64 的 pending 逐行显式列出。 +- [ ] 每行发行资源哈希不变 + 进程树零残留核对通过。 +- [ ] `plugin-functions`、`plugin-registered-settings-pages`、网页桥相关矩阵按实际范围更新(归属相应里程碑 PR,不在本文)。 + +第二阶段出口(M7–M9 完成后执行): + +- [ ] 原生 UI 示例、项目生成器包、VCS Provider 包各自完成适用子流程。 +- [ ] 生成器"生成 → 打开 → 语言服务 → 构建/运行"链有真实包证据。 +- [ ] arm64 pending 重新评估;仍缺实机则继续显式 pending。 +- [ ] 哈希/进程清理要求同第一阶段。 + +## 5. 数据来源与更新规则 + +- 仓库元数据/release/索引:gh API(`repos/Lithe-IDEA/*`、`repos/Lithe-IDEA/Lithe-store/contents/lithe-plugin-index.json`);许可取仓库 license 字段与包内 LICENSE 文件。 +- 契约语义与宿主边界:`shared/contracts/plugins/v1/README.md`、[Windows 声明式插件宿主 Note](../../.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md)、[Go/PHP 社区化 Note](../../.agents/notes/implemented/architecture/2026-10-20-go-php-community-plugins.md)、[Java 语言服务器插件 Note](../../.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md)。 +- 更新规则:本文只固定"验收输入的身份与步骤"。每次执行验收时重新 fetch 来源,把当时的 tag、版本、SHA-256、架构、宿主 SHA 写入当次验收记录;本文矩阵行与当次记录冲突时,以当次记录为准并回写本文。不得在本文新增下载物、私钥或无版本生成资源;不把模拟测试或单测当作实机交付。 diff --git a/docs/development/windows-plugin-build-implementation.md b/docs/development/windows-plugin-build-implementation.md new file mode 100644 index 0000000000..ea2832c032 --- /dev/null +++ b/docs/development/windows-plugin-build-implementation.md @@ -0,0 +1,143 @@ +# Windows 声明式构建执行模型(M4)实施清单 + +交付方式:本文下述 PR 切分现仅表示逻辑实现与验证批次。按用户决定,所有批次统一累计到一个 PR,不单独创建阶段或文档 PR。 + +状态:设计待实施。本文是 M4 设计的可执行拆解,不是已完成能力的记录。设计决策与理由以 +[proposed Note:Windows 声明式构建执行模型与安全调度](../../.agents/notes/proposed/architecture/2026-10-25-windows-declarative-build-execution.md) +为准;正式阶段边界以 [windows-plugin-parity-plan.md](windows-plugin-parity-plan.md) 的 M4 为准。 + +基线:`1e78b59b6385fca6a881639b42048bf3bc587f4a`(#1424 合入后的 preview)。M1 为 PR #1476 +(head `3408f781`,主审中),其信任链/安装事务修复合入前,本清单的宿主接线批次不得开始改 +`plugin_host` 产品代码。 + +## 0. 全局约定 + +- Core 14 个 `build.*` 命令(`rust/lithe-core/src/build/commands.rs:91-246`)只在宿主调用, + 不复制语义;宿主入口统一走 `plugin_host/scan.rs:190` 的 `core_execute`。 +- 前端命令按普通 Tauri 命令注册(`declarative_plugins_build_*` 前缀),不进 + `platform/tauri-core.ts` 的点分调度命名空间(见 `tauri-core.ts:18` 的规则与路由测试)。 +- `buildProviders` 全程关闭(M5);`buildSystems:[1,2]` 与 `executableResolution:[1]` 只在 + PR-C 行为完整验收后加入 `host_capabilities`(`plugin_host/mod.rs:37`)。 +- 矩阵:本清单对应的实现 PR 各自更新 `shared/platform-feature-matrix/features/` 相应条目; + `declarative-plugin-contract.json` 归 M1 所有,改动前先与 M1 协调。 + +## 1. M1 阻塞接口(只消费,不定义) + +| # | M1 提供 | M4 消费点 | +| --- | --- | --- | +| 1 | 包信任状态查询:已安装、已接受、已启用、当前版本字节与安装记录一致(含 M1 验签/摘要结果) | 每次构建执行的授权链第 1 步 | +| 2 | 安装审查已批准程序/权限记录(command 模型命令、任务/测试命令、打包运行时) | `projectFile`/`command` 执行的同意来源 | +| 3 | `lifecycle.rs` 隔离 API(`blocked/acquire/NativeLease`) | 构建进程注册为原生使用者,等待停用/替换/卸载 | +| 4 | `host_capabilities` 与 `declarative-plugin-contract` 矩阵的唯一修改权 | PR-C 串行在其后声明能力 | + +依赖规则:需要新增 M1 接口时,先在 proposed Note 验收标准补记录,由 M1 落地;M4 PR 不改 +`store.rs/install.rs` 的 schema。 + +## 2. PR-A:只读检测与展示(static 线) + +新增: + +- `windows/tauri/src-tauri/src/plugin_host/build.rs`:构建会话登记表(键 + `(host window label, workspace ID, pluginID, buildSystemID, instance root)`),detect、 + static 模型、`resolveOptions`(含 `dropped` 改存)、`syncFingerprint` 监听与过期标记、 + 选项持久化(全局 `app_local_data_dir()/plugin-build-options/`,按工作区 + buildSystemID; + 空值语义照抄 macOS:显式清空是有效值)。 +- `windows/tauri/src/features/build-systems/`:统一 TS service(每窗口展示状态)+ 工具窗口 + 注册(`.build-systems:`,插件启用即注册,无实例显示空状态)+ 实例 + 根选择器(候选与生效值取所选实例)。 + +修改:`main.rs`(命令注册)、`declarative-plugins-section.tsx`(无界面改动则不动)。 + +验收(真实接线): + +1. `build-static-file` fixture(Cargo)经真实 Core 识别,工具窗口展示工程/目标/任务树。 +2. 选项修改经 `build.resolveOptions` 校验后持久化;`dropped` 选项改存生效值;重开工作区保留。 +3. 构建文件变化 → `syncFingerprint` 变化 → 窗口显示"需要重新加载",不自动重跑。 +4. 停用插件 → 窗口与命令撤销(复用 `declarative-plugin-ui.ts` 撤销模式)。 + +有界测试(`plugin_host/tests.rs` 或新增 `build_tests.rs` + bun):并发 detect/load 用受控假 +执行器;登记表代次/槽位拒绝;无真实 sleep、无网络。 + +## 3. PR-B:进程执行与 Maven 对照 + +新增: + +- `windows/tauri/src-tauri/src/plugin_host/build_resolver.rs`:五种候选来源 + (`projectFile/environment/command/runtime/setting`,`rust/lithe-core/src/plugins/executables.rs:82`) + 的 Windows 解析;`build.invocationPlan` + `build.checkToolVersion`(复用 + `launch.rs:430` 的 `lithe_git_host::run` Job Object 执行器,5s 探测期限、每流上限); + setting 来源为空跳过、非空无效终止失败;Runtime 候选自带 PATH、失败候选不传递。 +- 命令模型加载:`build.modelPlan/decodeModel`、`build.dependencyPlan/decodeDependencies`; + stdout 单独捕获交 Core、stderr 进 Build Output;有界收集 + 取消 + 输出上限。 +- 任务/测试:`build.taskPlan/testPlan` 后经现有运行管线执行(`run.rs:675` + `run_resolve_launch`、`run.rs:68` `RunProcessManager`),登记构建归属; + `build.decodeDiagnostics/decodeTestResults` 结果进 Build Output,路径导航复用现有打开入口。 +- `build.languageImport` 接 Java 启动就绪链(`windows/tauri/src/platform/java-launch-readiness.ts`)。 + +修改:`plugin_host/build.rs`(执行槽位与 NativeLease)、`run.rs`(归属登记)、`web.rs` 不动。 + +验收(真实接线): + +1. `build-command-stdout`(Go)、`build-command-output-file`(Gradle)、 + `build-host-integration`(Maven)fixture 宿主行为覆盖;argv 与 `build-plans.json` 标准答案一致。 +2. 现有 Maven 专用流程(`maven.rs`、`features/maven/`)回归不退化;声明式 Maven 接管后从 + 空状态开始,不沿用专用缓存。 +3. 取消模型加载、等待同意时切换项目、配置路径带空格、setting 值失效(终止失败)全部覆盖。 +4. 停止后确认子进程树结束(Job Object),不是仅从列表移除。 + +有界测试:进程用固定夹具可执行文件与可控完成顺序;迟到输出/旧代次写回拒绝;取消先于启动。 + +## 4. PR-C:executableResolution、统一桥与能力开放 + +范围: + +- `executableResolution:1` 完整语义(排序元数据 `priority/insertBefore/insertAfter/platforms`, + `executables.rs:115` 的 `uses_resolution`)与 `buildSystems:2` 的 `invocation.executableSetting` + (`build/manifest.rs:1368` 门控:需要 v2 + effect reload/nextRun 的可执行全局设置)。 +- 工具路径用户选择复用声明式设置存储(`plugin_host/settings.rs:15` 全局 / `:26` 项目覆盖), + 与 Core `resolveOptions`/设置推送同源。 +- `web.rs` 8 个 `build.*` 方法(现拒绝于 `web.rs:787`)改为经登记表转发:页面身份 + (`with_page_owner`,`web.rs:80`)、同插件归属、与原生同一条授权链;`build.cancel` 只取消 + 本页面/本窗口操作;页面关闭/导航/工作区切换取消在途并丢弃迟到回复。 +- `host_capabilities` 增加 `"buildSystems": [1, 2], "executableResolution": [1]`(在 M1 合入 + 之后、本批验收通过后);能力门控测试随声明更新(参照 settings-v2 fixture 模式)。 +- 矩阵更新:`build-system-plugins.json`(Windows → implemented + pending)、 + `plugin-web-tool-windows.json`(build 方法接线说明)。 + +验收(真实接线): + +1. `build-tool-path` fixture 端到端:setting 显式无效 → 终止失败且不回退 PATH; + 空值 → 跳过候选继续数组顺序。 +2. 至少一个真实声明式构建插件完成:保存 → 同步 → 模型 → 运行/测试 → 停止 → 重新打开。 +3. 网页与原生同链:同一实例状态、同一忙碌冲突、同一取消语义;跨窗口互不可见。 +4. 停用/更新/回滚/卸载期间:`blocked` 拒绝新操作、已运行进程等待退出、页面关闭、 + 无任务用旧包字节重启。 + +有界测试:桥的跨 owner/过期代次/取消竞态;双窗口状态隔离;能力门控翻转前后对比。 + +## 5. 场景覆盖矩阵(每批必查) + +| 场景 | PR-A | PR-B | PR-C | +| --- | --- | --- | --- | +| 执行失败(缺可执行文件、非零退出、输出超限、解码失败) | – | ✔ | ✔ | +| 取消与迟到结果(用户取消/窗口关闭/切工作区/停用插件) | detect 级 | ✔ | ✔ | +| 跨窗口(互不取消、各自忙碌冲突、双窗口隔离) | 登记 | ✔ | ✔ | +| 过期结果(代次变化后迟到完成只标过期,不覆盖) | ✔ | ✔ | ✔ | +| 权限变化(M1 停用/更新/回滚/卸载、包字节替换) | 撤销窗口 | NativeLease | ✔ 全链 | +| 升级/卸载(进程树确认结束、登记表清理、无旧字节重启) | – | ✔ | ✔ | + +## 6. 验证命令(实现 PR 按范围选择) + +```powershell +node scripts/verify-agent-notes.mjs +cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml plugin_host +# 工作目录 windows/tauri +bun run typecheck +./scripts/verify-windows-boundaries.ps1 +./.agents/skills/write-stable-tests/scripts/verify-test-stability.ps1 +node scripts/generate-platform-feature-matrix.mjs --check +node scripts/verify-platform-feature-matrix-change.mjs +``` + +本清单与 proposed Note 属设计文档,未修改产品代码、共享契约、矩阵或测试;所有源码定位在 +基线 `1e78b59b` 核对,M1 相关结论以 PR #1476 合并后的最终形态为准。 diff --git a/docs/development/windows-plugin-fixture-coverage.md b/docs/development/windows-plugin-fixture-coverage.md new file mode 100644 index 0000000000..e4943d710c --- /dev/null +++ b/docs/development/windows-plugin-fixture-coverage.md @@ -0,0 +1,125 @@ +# Windows 插件 fixture 覆盖审计(M0) + +状态:M0 交付。证据在基线 commit `1e78b59b6385fca6a881639b42048bf3bc587f4a`(#1424 合入后的 preview 起点)上逐一核对,并已同步 preview `83017afa`(插件后端能力 `8471b998`/#1470 合入后的契约状态,新增 `backend.plugin.json`);行号为对应时点。 + +本文回答路线图 +[Windows 插件功能对齐计划](windows-plugin-parity-plan.md) M0 的要求:`shared/contracts/plugins/v1/fixtures` 的每个文件,是否有 Core 契约验证、是否有 Windows 产品宿主的**真实接线**测试,缺什么、归哪个阶段。机器可读清单是 +`scripts/plugin-fixture-coverage.json`,由 +`scripts/check-plugin-fixture-coverage.mjs` 持续校验;本文是人类可读的审计快照,两者由检测器的强制单测绑定。 + +**总原则:Core schema 校验通过不等于 Windows 产品完成。** `invalid-manifests.json` 的 154 个用例循环只证明"宿主校验路径对同一批非法输入报同一个稳定错误码",不证明安装、启动、停用、卸载、会话回收等宿主行为已经接线。下文按此口径标注。 + +## 1. 结论摘要 + +- fixtures 目录共 36 个文件(其中 signature-vectors.json 是本统一 PR 新登记的双端宿主签名向量,其余为原 35 项审计快照):20 个合法清单(含原生 UI 3 个、项目生成 1 个、VCS 1 个,分类时单列;backend 为冻结路线图后新增)、1 个非法修改案例集、5 个金样/计划文件(command-plans、build-plans、runtime-versions、function-protocol、project-plans)、5 个市场用例、4 个项目生成模板。另有 `README.md` 与两个 schema 在契约根目录,不在 fixtures 内。 +- Core 侧:35 个文件全部有至少一个 Core 测试引用(模板目录经 `plan.rs` 的 `collect_files` 生产代码递归读取,被 golden 测试间接触发)。 +- Windows 侧:**3 个 wired**(有产品路径测试)、**11 个 validated-only**(仅清单验收/能力门控)、**10 个 indirect-core**(Windows 经 Core 命令消费、无直接读取)、**12 个 unwired**(Windows 零引用)。 +- 最严重的真实缺口:`build-tool-path`、`build-provider-language-server`、`native-panel`、`native-form`、`native-controls`、`project-generator`、`vcs-provider`、`backend` 在 Windows 侧零引用;全部 5 个市场用例和 5 个跨平台金样在 Windows 没有直接消费测试。 +- 顺带发现(只记录,不修改,归属见 §5):`tests.rs:149` 的门控注释已过时;`lithe-plugin-docs` 的 `contract.lock.json` 落后于主仓库契约;macOS `BuildSystemToolWindowTests` 用本地 fixture 副本,共享 fixture 的改动不会反映到它。 + +## 2. 检测工具与运行方式 + +```bash +node scripts/check-plugin-fixture-coverage.mjs # 清单 vs 目录真实枚举 +node --test scripts/check-plugin-fixture-coverage.test.mjs +``` + +检测器只做本地只读比较,失败即退出码 1,并逐行打印问题。它强制: + +| 失败类型 | 含义 | +| --- | --- | +| `unknown-fixture` | 目录里出现清单未分类的新文件(新增 fixture 忘了登记) | +| `missing-fixture` | 清单条目指向的文件已不存在(失效路径) | +| `duplicate-entry` | 同一文件被分类两次 | +| `unsorted-entry` | 清单未按路径排序(保持 diff 可审) | +| `invalid-entry` | 枚举值非法、wired 无 Windows 证据、known gap 缺阶段/说明、路径逃逸等 | +| `evidence-path` | 证据引用的仓库文件不存在(测试文件被移动/删除后证据过期) | +| `io` | 清单/目录不可读 | + +单测覆盖正反例(临时目录构造,全部同步、有界、`t.after` 清理),最后一条强制真实仓库当前状态通过,并断言 known gap 数量大于零——**不允许把状态全部改成 wired 来伪造覆盖**。关闭一个 gap 的唯一方式是给出真实的 Windows 测试引用。 + +清单里每个条目的证据字符串格式是 `<仓库相对路径>[:<行号>] <测试名>`。检测器只校验文件存在;行号是基线时点的文档,允许随后续重构漂移,但文件消失必须处理。 + +## 3. 覆盖状态语义 + +| 字段 | 值 | 含义 | +| --- | --- | --- | +| `outcome` | success / rejection / partial-support | 契约角色:成功路径 / 拒绝路径 / 合法但当前双端宿主均未完整开放的能力(build provider 两形态) | +| `windowsStatus` | wired | Windows 有产品路径测试直接使用该 fixture(扫描、安装、启动、catalog、Web 窗口、前端注册等,不止清单校验) | +| | validated-only | 仅进入 Windows 清单验收(带/不带能力门控),无产品路径测试 | +| | indirect-core | Windows 通过 Core 命令消费其内容,宿主测试不直接读取该文件 | +| | unwired | Windows 零直接引用(或仅作为 invalid-manifests 基底间接读取,单独说明) | + +非 wired 的每条都带 `gapStage` 与 `gapDetail`。`gapStage` 表示**计划中该能力接入的阶段**,不是承诺日期;取值为路线图里程碑 M0–M10,或冻结路线图之后落地的新能力用的 `followup`(按计划 §6 单列后续阶段,暂无编号)。提前实现时以真实测试关闭并更新清单。 + +## 4. 分类明细 + +### 4.1 合法清单(15 个 valid-manifest;原生 UI、项目生成、VCS 三类单列见 §4.4) + +| Fixture | 结果 | Core 入口(节选) | Windows 入口 | Windows 状态 / 缺口 | +| --- | --- | --- | --- | --- | +| backend.plugin.json | success | `declarative_plugins.rs:32/615` 后端计划与宿主服务(8471b998) | 无 | **unwired** → followup:冻结路线图(M0–M10)之后的新能力,macOS 宿主已声明 `backend:[1]`,Windows 接入按计划 §6 单列后续阶段 | + +| Fixture | 结果 | Core 入口(节选) | Windows 入口 | Windows 状态 / 缺口 | +| --- | --- | --- | --- | --- | +| language-packaged-server | success | `declarative_plugins.rs:65` 全量验证;`:81` 归一化;`:251` API1 参数字面量;`marketplace_plugins.rs:219` 审查金样 | `tests.rs:112/162/196/258` 验收+扫描+存储+catalog;`install_tests.rs:43/118/185` 快照/staging/回滚;`launch.rs:933` 启动计划解析 | **wired** | +| hello-lithe | success | `declarative_plugins.rs:65/532/610` UI 贡献与图标归一化 | `tests.rs:112`;`web.rs:1657` 文本窗口注册;前端 `declarative-plugin-ui.test.ts:33` 三处命令位与撤销 | **wired** | +| language-system-command | success | `declarative_plugins.rs:65/106/330` 隐藏文件名、probe 不得用设置 | `tests.rs:112` 验收;`tests.rs:258` bundled catalog | validated-only → M10:systemCommand 启动(PATH 解析、`LITHE_*_PATH` 导出)无 fixture 级测试;macOS 侧也零引用 | +| language-host-integration | success | `declarative_plugins.rs:65/152/276` 集成能力归一化与审查 | `tests.rs:112` 仅验收 | validated-only → M10:jdt 集成启动计划与必需包资源无宿主测试(`launch.rs` 测试用 packaged-server),真实 JDT 包随 M10 | +| node-runtime | success | `declarative_plugins.rs:65/276/351` runtime 启动审查与设置归一化 | `tests.rs:112` 仅验收 | validated-only → M10:packagedRuntime/minimumVersion 与设置解析无 fixture 级测试(PHP 契约包端到端覆盖了 runtime node 计划展开,但不是本清单) | +| web-panel | success | `declarative_plugins.rs:65/563` 网页窗口许可与包页面 | `tests.rs:112` 仅验收(宿主已声明 `ui:[1,2]`) | validated-only → M6:本 fixture 声明的包页面没有被任何宿主测试打开;`build.*`/`functions.*` 桥按 v0 边界拒绝 | +| settings-v2 | success | `declarative_plugins.rs:901/911/958` 即时设置、分层解析、危险使用拒绝 | `tests.rs:112` 验收 + `tests.rs:128` 门控断言 | validated-only → M4:注册设置页、settingActions 与分层覆盖缺 fixture 级宿主行为测试(现有覆盖走设置编辑器与 `settings_tests`,不经本清单)。门控原因是该 fixture 同时 `requires buildSystems/buildProviders`;`tests.rs:149` 注释表述过时,见 §5 | +| build-static-file | success | `build_systems.rs:128/143/218/361/1236` 检测、golden、运行项、symlink 拒绝 | `tests.rs:112` + `:128` 门控 | validated-only → M4 | +| build-command-stdout | success | `build_systems.rs:128/143/1071` | `tests.rs:112` + `:128` | validated-only → M4 | +| build-command-output-file | success | `build_systems.rs:128/143` | `tests.rs:112` + `:128` | validated-only → M4 | +| build-host-integration | success | `build_systems.rs:128/143/482`;`build_runtime_versions.rs:15`;`project_generators.rs:92` | `tests.rs:112` + `:128` | validated-only → M4 | +| build-provider | partial-support | `declarative_plugins.rs:445`;`build_systems.rs:1115`;`marketplace_plugins.rs:219/332` | `tests.rs:112` + `:128` 门控 | validated-only → M5:自有进程 Provider 启动、litheBuild 协商、会话回收无宿主测试 | +| build-provider-language-server | partial-support | `declarative_plugins.rs:366/445`;`build_systems.rs:1460`;`marketplace_plugins.rs:219` | 仅经 `tests.rs:80` 作为 4 个非法用例基底间接读取 | **unwired** → M5:不在任何 Windows 显式清单,连"被门控拒绝"都无专属断言 | +| build-tool-path | success | `build_systems.rs:1599/1666/1719/1755` executableResolution 全部语义 | 无 | **unwired** → M4:Windows 零引用 | + +### 4.2 非法修改案例、跨平台金样与函数协议(6 个) + +| Fixture | 结果 | Core 入口 | Windows 入口 | Windows 状态 / 缺口 | +| --- | --- | --- | --- | --- | +| invalid-manifests.json(163 用例,13 个基底,含 backend) | rejection | `declarative_plugins.rs:133` | `tests.rs:80` 全量循环,宿主校验路径断言同码 | **wired**(校验路径一致性;宿主行为仍以各清单的 wired/validated-only 行为准) | +| command-plans.json | success | `declarative_plugins.rs:212` golden 展开 | 无;`tests.rs:433` 的 PHP 契约包端到端实测了 runPlan/testPlan 展开但不用本 golden | indirect-core → M5 | +| build-plans.json | success | `build_systems.rs:143` | 无 | indirect-core → M4 | +| runtime-versions.json | success | `declarative_plugins.rs:312` | 无(`launch.rs` 用 node/java/go 真实输出自测解析,不读本 golden) | indirect-core → M10:建议接入宿主探测路径时用本 fixture 对齐 | +| function-protocol.json | success | `plugin_function.rs:558` 起 8 组协议测试(v2 新增 hostRequests) | 无;网页桥 v0 对 `functions.*` 返回 `plugin.web.unknownMethod` | indirect-core → M6 | +| project-plans.json | success | `project_generators.rs:65` | 无 | indirect-core → M8 | + +### 4.3 市场用例(5 个 marketplace/) + +全部仅 Core:`marketplace_plugins.rs:100`(索引可用性)、`:131`(归一化排序)、`:147`(非法条目拒绝)、`:203`(来源 URL 解析)、`:219`(安装审查金样)、`:272`(ui 可用性需平台宿主)、`:332`(Provider 版本需宿主支持)。Windows 全部 indirect-core → M2:声明式市场宿主未接,索引消费、下载校验与来源记录均无 Windows 测试。其中 `install-reviews.json` 补充:Windows 磁盘安装链已调用 `installReview` 命令,但测试用 `language-packaged-server` 清单而非本审查金样矩阵。 + +### 4.4 原生 UI、项目生成与 VCS(9 个) + +| Fixture | Core 入口 | Windows 入口 | Windows 状态 / 缺口 | +| --- | --- | --- | --- | +| native-panel.plugin.json | `declarative_plugins.rs:666/785` 版本归属、动作与预算 | 无 | **unwired** → M7 | +| native-form.plugin.json | `declarative_plugins.rs:721/785` 表单值/动作/网格 | 无 | **unwired** → M7 | +| native-controls.plugin.json | `declarative_plugins.rs:827` 公共控件类型/范围/按钮 | 无 | **unwired** → M7 | +| project-generator.plugin.json | `project_generators.rs:65/92/259/327/339` 表单/计划/归档/审查 | 无 | **unwired** → M8 | +| project-generator-templates/.gitattributes | 经 `plan.rs collect_files` 递归读取(字节稳定标记) | 无 | indirect-core → M8 | +| project-generator-templates/app/README.md | 同上(files 步骤模板) | 无 | indirect-core → M8 | +| project-generator-templates/app/dot-gitignore | 同上(dot 命名规避 git 忽略) | 无 | indirect-core → M8 | +| project-generator-templates/main/Main.java | 同上(条件占位符模板) | 无 | indirect-core → M8 | +| vcs-provider.plugin.json | `vcs.rs:65/94/147/196` 审查、权限、启动计划、根识别 | 无 | **unwired** → M9 | + +## 5. 横切发现(记录,不在本 PR 修改) + +1. **过时门控注释**:`windows/tauri/src-tauri/src/plugin_host/tests.rs:149` 注释称 settings-v2 被门控是因为"settings 2 与 languageServers 2 未声明";实际 `mod.rs::host_capabilities` 已声明 `settings:[1,2]`、`languageServers:[1,2]`,真正的门控原因是该 fixture 同时 `requires buildSystems:1, buildProviders:1`。断言本身仍通过且有效,注释需在下次触碰该测试的 PR 中修正。 +2. **契约同步缺口**:`Lithe-IDEA/lithe-plugin-docs` 的 `contract.lock.json`(gh 核实于 2026-10-24)固定在旧修订 `18345cc9…`,文件清单缺少后加入的 `native-panel/native-form/native-controls`、`project-generator`、`project-generator-templates/` 全目录与 `project-plans.json`。契约 README 规定契约改动必须同步该仓库;这属于 [插件契约发布](../../.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md) 所有者的待办。 +3. **macOS 本地副本**:`macos/Tests/LitheTests/Fixtures/BuildSystems/` 下的 `build-command-output-file`、`build-host-integration` 等是共享 fixture 的本地副本,`BuildSystemToolWindowTests` 经 `Bundle.module` 加载副本。共享 fixture 语义变更不会自动反映到这批测试,属已知重复面,不在本轮收敛。 +4. **非法清单循环的边界**:Windows `invalid-manifests` 循环(154 用例)与 macOS 同名测试是契约一致性回归;它们不能替代宿主行为测试,也不能作为"Windows 已支持某能力"的证据——被门控清单(如 build 组)的全量拒绝恰恰说明能力未开放。 +5. **fixtur 目录枚举现状**:Core 测试全部用 `include_str!`/显式 const 清单,Windows 测试用 `read_fixture(name)` 显式点名;唯一对 fixtures 树的运行时递归读取是 `plan.rs` 的模板收集。这就是"新增 fixture 会静默漏掉"的结构性原因,也是本检测器存在的理由。`scripts/verify-shared-contracts.sh` 的语义校验(schemaVersion、ID 前缀、拒绝码文档化等)与本检测器互补,不互相替代。 +6. **过时的宿主能力描述**:`shared/contracts/plugins/v1/README.md` 能力一节(preview 上仍在 §456 附近)称 macOS 不声明 buildProviders/vcsProviders;基线与 preview 的 `PluginTypes.swift` `PluginManifest.hostCapabilities`(preview 为 `:492-504`)实际声明 `buildProviders:[1]、buildSystems:[1,2]、executableResolution:[1]、executionProviders:[1]、projectGenerators:[1]、ui:[1,2,3]、vcsProviders:[1]`(preview 另加 `backend:[1]`),Core `capabilities.rs` 的 `SUPPORTED_CAPABILITIES` 支持同一组(含 `buildSystems:2`)。功能矩阵佐证:`build-provider-refresh-and-tool` 与 `cargo-build-plugin` 记 macOS implemented/验证 pending。README 落后于代码,能力事实以 Core 与功能矩阵为准,不能以旧 README 推断安装可用性。 + +## 6. 维护规则 + +- 新增 fixture:同 PR 在 `scripts/plugin-fixture-coverage.json` 登记分类与双端证据;Windows 尚无测试时如实标 known gap(状态、阶段、说明),不得标 wired。 +- 删除/重命名 fixture:同 PR 清理清单,否则 `missing-fixture` 失败。 +- 移动/删除被引用的测试文件:更新证据条目,否则 `evidence-path` 失败。 +- 关闭 gap:以真实 Windows 测试为证据更新 `windowsStatus`/`windowsEvidence` 并清空 gap 字段;检测器会拒绝"没有证据的 wired"。 +- 统一交付:原 #1474、#1476、#1477 的改动收进同一分支与 PR;签名 fixture 和其分类同步提交。signature-vectors 类别允许 coreEvidence 为空,因为消费方是 Swift/Rust 宿主签名测试;当前按 validated-only/M1 记录,完整安装审批与失败恢复仍需生产路径验证。后续代码及覆盖登记只累积到统一 PR。 +- 本文是审计快照;实时约束以清单 + 检测器为准。行号漂移不需要修本文,但证据文件消失必须处理。 diff --git a/macos/Tests/LitheTests/PluginSignatureVectorTests.swift b/macos/Tests/LitheTests/PluginSignatureVectorTests.swift new file mode 100644 index 0000000000..ff47e5dc64 --- /dev/null +++ b/macos/Tests/LitheTests/PluginSignatureVectorTests.swift @@ -0,0 +1,180 @@ +import CryptoKit +import Foundation +import LithePluginPackageSigning +import Testing + +/// Cross-platform acceptance for the package signature: the same fixed +/// vectors answer identically on Swift/CryptoKit and the Windows Rust host. +/// The vectors live in +/// `shared/contracts/plugins/v1/fixtures/signature-vectors.json` and anchor +/// the Ed25519 primitive to the public RFC 8032 test key, so neither +/// platform's own round-trip can drift. +struct PluginSignatureVectorTests { + private struct Fixture: Codable { + struct Ed25519: Codable { + let seedHex: String + let publicKeyHex: String + let messageEmptySignatureHex: String + } + struct CanonicalPayload: Codable { + let keyID: String + let pluginID: String + let pluginVersion: String + let files: [String: String] + let payloadBase64: String + let signatureBase64: String + } + struct ContentDigest: Codable { + let files: [String: String] + let digest: String + } + + let ed25519: Ed25519 + let packageFiles: [String: String] + let canonicalPayload: CanonicalPayload + let contentDigest: ContentDigest + } + + private static func fixture() throws -> Fixture { + let url = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/signature-vectors.json") + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + private static func privateKey(from fixture: Fixture) throws -> Curve25519.Signing.PrivateKey { + var seed = Data() + var index = fixture.ed25519.seedHex.startIndex + while index < fixture.ed25519.seedHex.endIndex { + let next = fixture.ed25519.seedHex.index(index, offsetBy: 2) + seed.append(UInt8(fixture.ed25519.seedHex[index.. URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-signature-vector-\(UUID().uuidString)", isDirectory: true) + for (relativePath, content) in fixture.packageFiles { + let target = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: target.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data(content.utf8).write(to: target) + } + return root + } +} + +extension Data { + fileprivate var hex: String { + map { String(format: "%02x", $0) }.joined() + } +} diff --git a/scripts/check-plugin-fixture-coverage.mjs b/scripts/check-plugin-fixture-coverage.mjs new file mode 100644 index 0000000000..4e88c6a833 --- /dev/null +++ b/scripts/check-plugin-fixture-coverage.mjs @@ -0,0 +1,318 @@ +#!/usr/bin/env node +// Fixture coverage detector for shared/contracts/plugins/v1/fixtures. +// +// Every fixture file must appear exactly once in the explicit classification +// list scripts/plugin-fixture-coverage.json, and every list entry must point +// at a real file. This closes the "fixtures added without classification" +// hole: a new file in the directory fails with unknown-fixture, a stale list +// entry fails with missing-fixture, a file classified twice fails with +// duplicate-entry. The list must stay sorted so diffs stay reviewable, and a +// non-wired Windows status is a known gap that must name a roadmap stage +// (M0-M10) and a concrete detail until a real test closes it. +// +// Usage: +// node scripts/check-plugin-fixture-coverage.mjs +// [--manifest scripts/plugin-fixture-coverage.json] +// [--repo-root ] [--fixtures-dir ] +// +// Exits 0 when the enumeration and the list match and every entry is +// coherent; exits 1 after printing one line per problem. Read-only: it never +// writes anything and needs no network. Unit tests live in +// scripts/check-plugin-fixture-coverage.test.mjs. +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +export const CATEGORY_VALUES = [ + 'valid-manifest', + 'invalid-edits', + 'command-plans', + 'build-plans', + 'runtime-versions', + 'function-protocol', + 'project-plans', + 'project-generation', + 'marketplace', + // Shared acceptance data for publisher-signature verification (M1); the + // fixture and its classification ship together in the unified plugin PR. + // Both hosts consume it in their own signature tests, so there is no Core + // validation surface and coreEvidence is optional for this category. + 'signature-vectors', + 'native-ui', + 'vcs-provider', + 'template-assets', +]; + +export const OUTCOME_VALUES = ['success', 'rejection', 'partial-support']; + +export const WINDOWS_STATUS_VALUES = ['wired', 'validated-only', 'indirect-core', 'unwired']; + +// Roadmap stages M0-M10, plus `followup` for capabilities that landed after +// the roadmap froze: the plan routes those into separately numbered later +// stages (section 6) that do not exist yet, so no M-number may be invented. +export const GAP_STAGE_PATTERN = /^(M(10|[0-9])|followup)$/; + +export function defaultRepoRoot() { + return join(dirname(fileURLToPath(import.meta.url)), '..'); +} + +export function defaultManifestPath() { + return join(defaultRepoRoot(), 'scripts', 'plugin-fixture-coverage.json'); +} + +// Recursively lists regular files under fixturesDir as '/'-separated paths +// relative to it, in the same byte order the manifest must be sorted with. +// Any non-directory, non-regular entry (symlink, junction) is reported as a +// fixture candidate so an anomalous entry cannot hide behind the enumeration. +export function enumerateFixtureFiles(fixturesDir) { + if (!statSync(fixturesDir, { throwIfNoEntry: false })?.isDirectory()) { + throw new Error(`fixtures directory is not readable: ${fixturesDir}`); + } + const found = []; + const walk = (absolute, prefix) => { + for (const entry of readdirSync(absolute, { withFileTypes: true })) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(join(absolute, entry.name), relativePath); + } else { + found.push(relativePath); + } + } + }; + walk(fixturesDir, ''); + found.sort(comparePaths); + return found; +} + +function comparePaths(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +// An evidence string is '[:] '. +// Only the file part is enforced (test files move); the line number is +// documentation of where the evidence was read at the audit's base commit. +export function evidenceFilePath(evidence) { + const token = evidence.trim().split(/\s+/)[0] ?? ''; + const match = token.match(/^(.+):(\d+)$/); + return match ? match[1] : token; +} + +// Checks the classification list against the real directory enumeration. +// Returns { ok, problems, summary }; problems are { kind, detail } and the +// summary counts fixtures, categories, wired entries, and known gaps. +export function runCoverageCheck({ repoRoot, fixturesDir, manifest }) { + const problems = []; + const push = (kind, detail) => problems.push({ kind, detail }); + + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + return { + ok: false, + problems: [{ kind: 'io', detail: 'manifest is not a JSON object' }], + summary: null, + }; + } + if (manifest.schemaVersion !== 1) { + push('io', `manifest schemaVersion must be 1, got ${JSON.stringify(manifest.schemaVersion)}`); + } + const fixtures = manifest.fixtures; + if (!Array.isArray(fixtures)) { + push('io', 'manifest fixtures must be an array'); + return { ok: false, problems, summary: null }; + } + + let onDisk; + try { + onDisk = enumerateFixtureFiles(fixturesDir); + } catch (error) { + push('io', String(error.message ?? error)); + return { ok: false, problems, summary: null }; + } + + const byPath = new Map(); + let previousPath = null; + for (const [index, entry] of fixtures.entries()) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + push('invalid-entry', `fixtures[${index}] is not an object`); + continue; + } + const path = entry.path; + if (typeof path !== 'string' || path.length === 0) { + push('invalid-entry', `fixtures[${index}].path must be a non-empty string`); + continue; + } + if (byPath.has(path)) { + push('duplicate-entry', `${path} is classified at indexes ${byPath.get(path)} and ${index}`); + continue; + } + byPath.set(path, index); + if (previousPath !== null && comparePaths(previousPath, path) >= 0) { + push('unsorted-entry', `${path} must come after ${previousPath}; keep the list sorted`); + } + previousPath = path; + validateEntry(entry, repoRoot, push); + } + + const listed = new Set(byPath.keys()); + for (const path of onDisk) { + if (!listed.has(path)) push('unknown-fixture', `${path} exists in the fixtures directory but is not classified`); + } + for (const path of listed) { + if (!onDisk.includes(path)) push('missing-fixture', `${path} is classified but does not exist in the fixtures directory`); + } + + const classified = [...byPath.keys()]; + const summary = { + fixtures: classified.length, + onDisk: onDisk.length, + byCategory: {}, + wired: 0, + knownGaps: 0, + gapStages: {}, + }; + for (const path of classified) { + const entry = fixtures[byPath.get(path)]; + if (!entry || typeof entry !== 'object') continue; + summary.byCategory[entry.category] = (summary.byCategory[entry.category] ?? 0) + 1; + if (entry.windowsStatus === 'wired') summary.wired += 1; + else { + summary.knownGaps += 1; + if (typeof entry.gapStage === 'string') { + summary.gapStages[entry.gapStage] = (summary.gapStages[entry.gapStage] ?? 0) + 1; + } + } + } + + return { ok: problems.length === 0, problems, summary }; +} + +function validateEntry(entry, repoRoot, push) { + const path = entry.path; + if (typeof path === 'string' && path.length > 0) { + if (path.includes('\\') || path.startsWith('/') || path.split('/').includes('..')) { + push('invalid-entry', `${path}: fixture paths must be '/'-separated and stay inside the fixtures directory`); + } + } + if (!CATEGORY_VALUES.includes(entry.category)) { + push('invalid-entry', `${path ?? ''}: unknown category ${JSON.stringify(entry.category)}`); + } + if (!OUTCOME_VALUES.includes(entry.outcome)) { + push('invalid-entry', `${path ?? ''}: unknown outcome ${JSON.stringify(entry.outcome)}`); + } + if (!WINDOWS_STATUS_VALUES.includes(entry.windowsStatus)) { + push('invalid-entry', `${path ?? ''}: unknown windowsStatus ${JSON.stringify(entry.windowsStatus)}`); + } + checkEvidence(entry.path, entry.coreEvidence, 'coreEvidence', repoRoot, push, entry.category !== 'signature-vectors'); + checkEvidence(entry.path, entry.windowsEvidence, 'windowsEvidence', repoRoot, push, true); + + const wired = entry.windowsStatus === 'wired'; + if (wired) { + if (!Array.isArray(entry.windowsEvidence) || entry.windowsEvidence.length === 0) { + push('invalid-entry', `${path}: windowsStatus "wired" requires at least one windowsEvidence entry`); + } + if (entry.gapStage !== null && entry.gapStage !== undefined) { + push('invalid-entry', `${path}: a wired fixture must not carry gapStage`); + } + if (entry.gapDetail !== null && entry.gapDetail !== undefined) { + push('invalid-entry', `${path}: a wired fixture must not carry gapDetail`); + } + } else if (WINDOWS_STATUS_VALUES.includes(entry.windowsStatus)) { + if (typeof entry.gapStage !== 'string' || !GAP_STAGE_PATTERN.test(entry.gapStage)) { + push('invalid-entry', `${path}: a known gap must name a roadmap stage (M0-M10), got ${JSON.stringify(entry.gapStage)}`); + } + if (typeof entry.gapDetail !== 'string' || entry.gapDetail.trim().length === 0) { + push('invalid-entry', `${path}: a known gap must describe what is missing in gapDetail`); + } + } +} + +function checkEvidence(path, evidence, field, repoRoot, push, required) { + if (evidence === undefined) { + if (required) push('invalid-entry', `${path ?? ''}: ${field} is required`); + return; + } + if (!Array.isArray(evidence)) { + push('invalid-entry', `${path ?? ''}: ${field} must be an array of strings`); + return; + } + if (required && field === 'coreEvidence' && evidence.length === 0) { + push('invalid-entry', `${path}: coreEvidence must name at least one Core-side reference`); + return; + } + for (const item of evidence) { + if (typeof item !== 'string' || item.trim().length === 0) { + push('invalid-entry', `${path ?? ''}: ${field} entries must be non-empty strings`); + continue; + } + const file = evidenceFilePath(item); + if (file.length === 0) { + push('invalid-entry', `${path}: ${field} entry does not start with a file path: ${item}`); + continue; + } + if (!existsSync(join(repoRoot, ...file.split('/')))) { + push('evidence-path', `${path}: ${field} references a missing file: ${file}`); + } + } +} + +// Fixture-set drift is the failure CI cannot fix by rerunning; point the +// person reading a red check at the one file that resolves it. +const FIXTURE_SET_PROBLEMS = new Set(['unknown-fixture', 'missing-fixture', 'duplicate-entry', 'unsorted-entry']); + +function printReport(result) { + const { problems, summary } = result; + for (const problem of problems) console.error(`${problem.kind}: ${problem.detail}`); + if (problems.some((problem) => FIXTURE_SET_PROBLEMS.has(problem.kind))) { + console.error('hint: register or clean up fixtures in scripts/plugin-fixture-coverage.json; the maintenance rules are section 6 of docs/development/windows-plugin-fixture-coverage.md'); + } + if (summary) { + const stages = Object.entries(summary.gapStages) + .sort(comparePaths) + .map(([stage, count]) => `${stage}:${count}`) + .join(' '); + console.log( + `fixtures=${summary.fixtures} onDisk=${summary.onDisk} wired=${summary.wired} knownGaps=${summary.knownGaps}` + + (stages ? ` gapStages(${stages})` : ''), + ); + } +} + +function main() { + const { values } = parseArgs({ + options: { + manifest: { type: 'string' }, + 'repo-root': { type: 'string' }, + 'fixtures-dir': { type: 'string' }, + }, + }); + const repoRoot = values['repo-root'] ?? defaultRepoRoot(); + const manifestPath = values.manifest ?? join(repoRoot, 'scripts', 'plugin-fixture-coverage.json'); + + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + } catch (error) { + console.error(`io: cannot read manifest ${manifestPath}: ${error.message}`); + process.exitCode = 1; + return; + } + + const fixturesDir = + values['fixtures-dir'] ?? + (typeof manifest.fixturesDirectory === 'string' ? join(repoRoot, ...manifest.fixturesDirectory.split('/')) : null); + if (!fixturesDir) { + console.error('io: manifest fixturesDirectory is missing and --fixtures-dir was not given'); + process.exitCode = 1; + return; + } + + const result = runCoverageCheck({ repoRoot, fixturesDir, manifest }); + printReport(result); + process.exitCode = result.ok ? 0 : 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/scripts/check-plugin-fixture-coverage.test.mjs b/scripts/check-plugin-fixture-coverage.test.mjs new file mode 100644 index 0000000000..5344aa434a --- /dev/null +++ b/scripts/check-plugin-fixture-coverage.test.mjs @@ -0,0 +1,404 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + defaultManifestPath, + defaultRepoRoot, + enumerateFixtureFiles, + evidenceFilePath, + GAP_STAGE_PATTERN, + runCoverageCheck, +} from './check-plugin-fixture-coverage.mjs'; + +// A throwaway repository shaped like the real one: a fixtures directory, a +// classification manifest, and evidence files the manifest can point at. +// Every wait is synchronous filesystem work, so the tests are deterministic +// and clean their temp roots through t.after on success and failure alike. +function tempRepo(t) { + const root = mkdtempSync(join(tmpdir(), 'lithe-fixture-coverage-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const fixturesDirectory = 'shared/contracts/plugins/v1/fixtures'; + const fixturesDir = join(root, ...fixturesDirectory.split('/')); + return { + root, + fixturesDir, + fixturesDirectory, + writeFixture(relativePath) { + mkdirSync(dirname(join(fixturesDir, ...relativePath.split('/'))), { recursive: true }); + writeFileSync(join(fixturesDir, ...relativePath.split('/')), '{}\n'); + }, + writeFile(relativePath, text) { + mkdirSync(dirname(join(root, ...relativePath.split('/'))), { recursive: true }); + writeFileSync(join(root, ...relativePath.split('/')), text); + }, + writeManifest(entries, extra = {}) { + const manifestPath = join(root, 'scripts', 'plugin-fixture-coverage.json'); + mkdirSync(dirname(manifestPath), { recursive: true }); + writeFileSync( + manifestPath, + JSON.stringify({ schemaVersion: 1, fixturesDirectory, fixtures: entries, ...extra }, null, 2), + ); + }, + check() { + const manifest = JSON.parse(readFileSync(join(root, 'scripts', 'plugin-fixture-coverage.json'), 'utf8')); + return runCoverageCheck({ repoRoot: root, fixturesDir, manifest }); + }, + }; +} + +// Evidence strings are repo-relative, like the real manifest's. +const fixtureEvidence = (relativePath) => `shared/contracts/plugins/v1/fixtures/${relativePath}:1 exists`; + +function entry(path, overrides = {}) { + const evidence = [fixtureEvidence(path)]; + return { + path, + category: 'valid-manifest', + outcome: 'success', + coreEvidence: evidence, + windowsEvidence: evidence, + windowsStatus: 'wired', + gapStage: null, + gapDetail: null, + ...overrides, + }; +} + +const kinds = (result) => result.problems.map((problem) => problem.kind); + +test('a matching manifest and directory pass with an honest summary', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.plugin.json'); + repo.writeFixture('nested/b.json'); + repo.writeManifest([ + entry('a.plugin.json'), + entry('nested/b.json', { + category: 'marketplace', + windowsStatus: 'unwired', + gapStage: 'M2', + gapDetail: 'no Windows consumer yet', + windowsEvidence: [], + }), + ]); + const result = repo.check(); + assert.equal(result.ok, true); + assert.deepEqual(result.problems, []); + assert.equal(result.summary.fixtures, 2); + assert.equal(result.summary.wired, 1); + assert.equal(result.summary.knownGaps, 1); + assert.deepEqual(result.summary.gapStages, { M2: 1 }); + assert.deepEqual(result.summary.byCategory, { 'valid-manifest': 1, marketplace: 1 }); +}); + +test('a new file in the fixtures directory fails as unknown', (t) => { + const repo = tempRepo(t); + repo.writeFixture('classified.json'); + repo.writeFixture('surprise.plugin.json'); + repo.writeManifest([entry('classified.json')]); + const result = repo.check(); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['unknown-fixture']); + assert.match(result.problems[0].detail, /surprise\.plugin\.json/); +}); + +test('a classified file that no longer exists fails as missing', (t) => { + const repo = tempRepo(t); + repo.writeFixture('present.json'); + repo.writeManifest([ + entry('present.json'), + // Its evidence points at a surviving file so only the missing fixture is reported. + entry('removed.json', { + coreEvidence: [fixtureEvidence('present.json')], + windowsEvidence: [fixtureEvidence('present.json')], + }), + ]); + const result = repo.check(); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['missing-fixture']); + assert.match(result.problems[0].detail, /removed\.json/); +}); + +test('classifying the same fixture twice fails', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeManifest([entry('a.json'), entry('a.json')]); + const result = repo.check(); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['duplicate-entry']); +}); + +test('an unsorted list fails so diffs stay reviewable', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeFixture('b.json'); + repo.writeManifest([entry('b.json'), entry('a.json')]); + const result = repo.check(); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['unsorted-entry']); +}); + +test('incoherent entries fail: unknown enums, wired without evidence, gap without stage', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeFixture('b.json'); + repo.writeFixture('c.json'); + repo.writeFixture('d.json'); + repo.writeManifest([ + entry('a.json', { category: 'mystery', windowsStatus: 'indirect-core', gapStage: 'M4', gapDetail: 'x', windowsEvidence: [] }), + entry('b.json', { windowsEvidence: [] }), + entry('c.json', { windowsStatus: 'indirect-core', gapDetail: 'stage forgotten', windowsEvidence: [] }), + entry('d.json', { coreEvidence: [] }), + ]); + const result = repo.check(); + assert.equal(result.ok, false); + const details = result.problems.map((problem) => problem.detail); + assert.ok(details.some((detail) => detail.includes('unknown category "mystery"')), details.join('\n')); + assert.ok(details.some((detail) => detail.includes('b.json: windowsStatus "wired" requires')), details.join('\n')); + assert.ok(details.some((detail) => detail.includes('c.json: a known gap must name a roadmap stage')), details.join('\n')); + assert.ok(details.some((detail) => detail.includes('d.json: coreEvidence must name')), details.join('\n')); +}); + +test('a wired fixture must not carry gap fields and a gap must describe the miss', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeFixture('b.json'); + repo.writeManifest([ + entry('a.json', { gapStage: 'M4', gapDetail: 'stray' }), + entry('b.json', { windowsStatus: 'unwired', gapStage: 'M4', gapDetail: ' ' }), + ]); + const result = repo.check(); + assert.equal(result.ok, false); + const details = result.problems.map((problem) => problem.detail); + assert.ok(details.some((detail) => detail.includes('a.json: a wired fixture must not carry gapStage')), details.join('\n')); + assert.ok(details.some((detail) => detail.includes('b.json: a known gap must describe')), details.join('\n')); +}); + +test('evidence must point at files that exist in the repository', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeManifest([ + entry('a.json', { + coreEvidence: ['rust/lithe-core/src/tests/gone.rs:12 vanished'], + windowsEvidence: [fixtureEvidence('a.json')], + }), + ]); + const result = repo.check(); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['evidence-path']); + assert.match(result.problems[0].detail, /rust\/lithe-core\/src\/tests\/gone\.rs/); +}); + +test('fixture paths must stay inside the fixtures directory', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeManifest([entry('../outside.json')]); + const result = repo.check(); + assert.equal(result.ok, false); + assert.ok(kinds(result).includes('invalid-entry'), kinds(result).join(', ')); + assert.ok(result.problems.some((problem) => /stay inside the fixtures directory/.test(problem.detail))); + // The escaping path must not silently pass the set comparison either. + assert.ok(kinds(result).includes('unknown-fixture'), 'a.json on disk goes unclassified'); + assert.ok(kinds(result).includes('missing-fixture'), 'the escaped entry matches no file'); +}); + +test('a missing fixtures directory is an io problem, not a crash', (t) => { + const root = mkdtempSync(join(tmpdir(), 'lithe-fixture-coverage-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const result = runCoverageCheck({ + repoRoot: root, + fixturesDir: join(root, 'does-not-exist'), + manifest: { schemaVersion: 1, fixtures: [] }, + }); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['io']); + assert.equal(result.summary, null); +}); + +test('a non-object manifest is an io problem', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + const result = runCoverageCheck({ repoRoot: repo.root, fixturesDir: repo.fixturesDir, manifest: [] }); + assert.equal(result.ok, false); + assert.deepEqual(kinds(result), ['io']); +}); + +test('enumeration is recursive, uses / separators, and is sorted', (t) => { + const repo = tempRepo(t); + repo.writeFixture('zeta.json'); + repo.writeFixture('nested/deep/gamma.json'); + repo.writeFixture('alpha.json'); + assert.deepEqual(enumerateFixtureFiles(repo.fixturesDir), [ + 'alpha.json', + 'nested/deep/gamma.json', + 'zeta.json', + ]); +}); + +test('evidence parsing keeps the file part and drops the line number', () => { + assert.equal(evidenceFilePath('rust/lithe-core/src/tests/vcs.rs:65 a name'), 'rust/lithe-core/src/tests/vcs.rs'); + assert.equal(evidenceFilePath('windows/tauri/src-tauri/src/plugin_host/tests.rs name'), 'windows/tauri/src-tauri/src/plugin_host/tests.rs'); + assert.equal(evidenceFilePath('shared/contracts/plugins/v1/fixtures/x.json'), 'shared/contracts/plugins/v1/fixtures/x.json'); +}); + +// The enforcement test: the real manifest must match the real fixtures +// directory right now, and every non-wired entry must keep an honest gap +// classification. Completeness is never asserted here - the day every +// fixture is genuinely wired on Windows this test must still pass; the +// all-wired positive case below proves that path exists. +test('the real fixture directory and classification list agree', () => { + const repoRoot = defaultRepoRoot(); + const manifest = JSON.parse(readFileSync(defaultManifestPath(), 'utf8')); + const fixturesDir = join(repoRoot, ...manifest.fixturesDirectory.split('/')); + const result = runCoverageCheck({ repoRoot, fixturesDir, manifest }); + assert.equal(result.ok, true, result.problems.map((problem) => `${problem.kind}: ${problem.detail}`).join('\n')); + assert.ok(result.summary.fixtures >= 30, `expected the full fixture set, got ${result.summary.fixtures}`); + for (const fixture of manifest.fixtures) { + if (fixture.windowsStatus !== 'wired') { + assert.match(fixture.gapStage, GAP_STAGE_PATTERN, `${fixture.path} gap stage`); + assert.ok(fixture.gapDetail.trim().length > 0, `${fixture.path} gap detail`); + } + } +}); + +// The anti-forgery stance is honesty, not permanent partiality: a manifest +// that wires every fixture with real evidence must pass with zero known +// gaps, so the gate can never be satisfied by keeping statuses partial. +test('a fully wired manifest with valid evidence passes with zero known gaps', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeFixture('b.json'); + repo.writeManifest([entry('a.json'), entry('b.json')]); + const result = repo.check(); + assert.equal(result.ok, true, result.problems.map((problem) => `${problem.kind}: ${problem.detail}`).join('\n')); + assert.equal(result.summary.knownGaps, 0); + assert.equal(result.summary.wired, 2); +}); + +test('M0 and followup are legal gap stages, signature-vectors a legal category, M11 is not', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeFixture('b.json'); + repo.writeFixture('c.json'); + repo.writeManifest([ + entry('a.json', { + category: 'signature-vectors', + windowsStatus: 'validated-only', + gapStage: 'M0', + gapDetail: 'baseline problem owned by an M0 focused fix PR', + windowsEvidence: [], + }), + entry('b.json', { windowsStatus: 'unwired', gapStage: 'M0', gapDetail: 'also owned by M0', windowsEvidence: [] }), + entry('c.json', { + windowsStatus: 'unwired', + gapStage: 'followup', + gapDetail: 'capability landed after the roadmap froze; awaiting its numbered stage', + windowsEvidence: [], + }), + ]); + const result = repo.check(); + assert.equal(result.ok, true, result.problems.map((problem) => `${problem.kind}: ${problem.detail}`).join('\n')); + assert.equal(result.summary.gapStages.M0, 2); + assert.equal(result.summary.gapStages.followup, 1); + + repo.writeManifest([ + entry('a.json', { windowsStatus: 'unwired', gapStage: 'M11', gapDetail: 'beyond the roadmap', windowsEvidence: [] }), + ]); + const rejected = repo.check(); + assert.equal(rejected.ok, false); + assert.ok(rejected.problems.some((problem) => /must name a roadmap stage/.test(problem.detail))); +}); + +// Signature vectors are shared acceptance data consumed by each host's own +// signature tests; there is no Core validation surface, so coreEvidence may +// be empty as long as the Windows side carries real evidence. +test('signature-vectors entries may carry host-side evidence only', (t) => { + const repo = tempRepo(t); + repo.writeFixture('signature-vectors.json'); + // The Windows signature test file the evidence points at must exist in the + // throwaway repository, mirroring the real M1 tree. + repo.writeFile('windows/tauri/src-tauri/src/plugin_host/signature.rs', 'mod tests {}'); + repo.writeManifest([ + entry('signature-vectors.json', { + category: 'signature-vectors', + outcome: 'success', + coreEvidence: [], + windowsEvidence: ['windows/tauri/src-tauri/src/plugin_host/signature.rs:663 shared_fixture_verifies_end_to_end'], + }), + ]); + const result = repo.check(); + assert.equal(result.ok, true, result.problems.map((problem) => `${problem.kind}: ${problem.detail}`).join('\n')); + + // The exemption must not become a hole: other categories still require + // Core-side evidence even when Windows evidence is present. + repo.writeFixture('plain.json'); + repo.writeManifest([ + entry('signature-vectors.json', { + category: 'signature-vectors', + coreEvidence: [], + windowsEvidence: ['windows/tauri/src-tauri/src/plugin_host/signature.rs:663 shared_fixture_verifies_end_to_end'], + }), + entry('plain.json', { coreEvidence: [], windowsEvidence: [fixtureEvidence('plain.json')] }), + ]); + const rejected = repo.check(); + assert.equal(rejected.ok, false); + assert.ok(rejected.problems.some((problem) => /plain\.json: coreEvidence must name/.test(problem.detail))); +}); + +// CI wiring guard: the Linux changes lane must invoke both the detector CLI +// and this test file. If the workflow stops calling them, a new fixture can +// land unregistered without any real pull request check failing. +test('the Linux changes lane wires the detector and its tests into CI', () => { + const workflow = readFileSync(join(defaultRepoRoot(), '.github', 'workflows', 'ci-macos.yml'), 'utf8'); + assert.ok(workflow.includes('run: node scripts/check-plugin-fixture-coverage.mjs'), 'detector CLI gate step missing'); + assert.ok(workflow.includes('scripts/check-plugin-fixture-coverage.test.mjs'), 'test invocation missing'); + assert.ok(workflow.includes('--test-timeout=15000'), 'per-test deadline missing'); + assert.ok(workflow.includes('plugin-fixture-coverage.junit.xml'), 'junit timing evidence missing'); +}); + +// CI runs the CLI, not the library function, so the executable boundary +// itself gets covered: exit codes, the summary line, and the registration +// hint on fixture-set drift. Every invocation has a hard deadline. +const cliPath = fileURLToPath(new URL('./check-plugin-fixture-coverage.mjs', import.meta.url)); + +function runCli(arguments_, { cwd } = {}) { + try { + return { + status: 0, + stdout: execFileSync(process.execPath, [cliPath, ...arguments_], { encoding: 'utf8', timeout: 10000, cwd }), + }; + } catch (error) { + return { status: error.status ?? 1, stdout: error.stdout ?? '', stderr: error.stderr ?? '' }; + } +} + +test('the CLI exits zero with a summary on a clean tree', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeManifest([entry('a.json')]); + const run = runCli([ + '--repo-root', repo.root, + '--manifest', join(repo.root, 'scripts', 'plugin-fixture-coverage.json'), + '--fixtures-dir', repo.fixturesDir, + ]); + assert.equal(run.status, 0, run.stderr); + assert.match(run.stdout, /fixtures=1 onDisk=1 wired=1 knownGaps=0/); +}); + +test('the CLI exits nonzero, names the problem, and points at the manifest', (t) => { + const repo = tempRepo(t); + repo.writeFixture('a.json'); + repo.writeFixture('unregistered.json'); + repo.writeManifest([entry('a.json')]); + const run = runCli([ + '--repo-root', repo.root, + '--manifest', join(repo.root, 'scripts', 'plugin-fixture-coverage.json'), + '--fixtures-dir', repo.fixturesDir, + ]); + assert.equal(run.status, 1); + assert.match(run.stderr, /unknown-fixture: unregistered\.json/); + assert.match(run.stderr, /hint: register or clean up fixtures in scripts\/plugin-fixture-coverage\.json/); +}); diff --git a/scripts/plugin-fixture-coverage.json b/scripts/plugin-fixture-coverage.json new file mode 100644 index 0000000000..ebcbbfec6a --- /dev/null +++ b/scripts/plugin-fixture-coverage.json @@ -0,0 +1,547 @@ +{ + "schemaVersion": 1, + "description": "Explicit classification of every file under shared/contracts/plugins/v1/fixtures. Checked by scripts/check-plugin-fixture-coverage.mjs (wired into the ci-macos.yml changes job on Linux): the directory enumeration and this list must match exactly. windowsStatus: wired = a product-path Windows test exercises this fixture; validated-only = only manifest validation/gating; indirect-core = Windows consumes it through Core commands without reading the file; unwired = no Windows list or test names it. A non-wired status is a known gap and must keep gapStage/gapDetail until a real test closes it; gapStage is a roadmap stage M0-M10 or 'followup' for capabilities that landed after the roadmap froze. Evidence strings are '[:] '; the checker verifies the file exists, lines are documentation as of base commit 1e78b59b6385fca6a881639b42048bf3bc587f4a synced with preview 83017afa851c83f119f40268bf9eb04b26aefc91 (backend capability).", + "fixturesDirectory": "shared/contracts/plugins/v1/fixtures", + "fixtures": [ + { + "path": "backend.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:32 BACKEND_FIXTURE", + "rust/lithe-core/src/tests/declarative_plugins.rs:615 plugin_backend_is_planned_without_a_workspace_and_names_its_host_services" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "followup", + "gapDetail": "Windows 侧零引用。插件后端(backend:1)与函数协议 v2 是冻结路线图(2026-10-24 版 M0–M10)之后的新能力(preview 8471b998/#1470),macOS 宿主已声明 backend:[1](PluginTypes.swift:493);Windows 接入按计划 §6 单列后续阶段,暂无编号。" + }, + { + "path": "build-command-output-file.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:128 build_fixtures_validate", + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (13 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections" + ], + "windowsStatus": "validated-only", + "gapStage": "M4", + "gapDetail": "Windows 只做到清单验收与能力门控;输出文件模型解码(${outputFile}、JUnit 路径)没有任何宿主接线测试,属声明式构建接入。" + }, + { + "path": "build-command-stdout.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:128 build_fixtures_validate", + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens", + "rust/lithe-core/src/tests/build_systems.rs:1071 resolve_options_describe_explicit_empty_text_values", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (9 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections" + ], + "windowsStatus": "validated-only", + "gapStage": "M4", + "gapDetail": "Windows 只有清单验收与能力门控;stdout 模型解码、选项解析与检测优先级无宿主接线测试,属声明式构建接入。" + }, + { + "path": "build-host-integration.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:128 build_fixtures_validate", + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens", + "rust/lithe-core/src/tests/build_systems.rs:482 maven_reactor_detects_through_the_host_integration", + "rust/lithe-core/src/tests/build_runtime_versions.rs:15 host_runtime_probe_arguments_are_portable_across_platforms", + "rust/lithe-core/src/tests/project_generators.rs:92 catalog_lists_generators_with_requirement_providers", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (5 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections" + ], + "windowsStatus": "validated-only", + "gapStage": "M4", + "gapDetail": "Windows 只有清单验收与能力门控;hostIntegration 模型来源(Maven reactor 探测、运行时探测参数)无宿主接线测试,属声明式构建接入。" + }, + { + "path": "build-plans.json", + "category": "build-plans", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M4", + "gapDetail": "Windows 无直接读取;声明式构建接入(M4)时,宿主模型/任务/测试/诊断行为覆盖应以本 golden 对齐。" + }, + { + "path": "build-provider-language-server.plugin.json", + "category": "valid-manifest", + "outcome": "partial-support", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:128 build_fixtures_validate", + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens", + "rust/lithe-core/src/tests/build_systems.rs:1460 launch_plans_name_exactly_one_process", + "rust/lithe-core/src/tests/declarative_plugins.rs:366 level_3_manifests_list_exactly_the_capabilities_they_use", + "rust/lithe-core/src/tests/declarative_plugins.rs:445 build_providers_require_their_own_host_capability", + "rust/lithe-core/src/tests/marketplace_plugins.rs:219 install_review_fixtures_match_golden_outcomes" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:80 invalid_manifest_fixtures_keep_their_stable_codes (only read indirectly as 4 case bases)" + ], + "windowsStatus": "unwired", + "gapStage": "M5", + "gapDetail": "不在 Windows 任何合法/门控清单中,仅作为 invalid-manifests 用例基底被间接读取;连'被能力门控拒绝'这一断言都没有专属覆盖。共享语言服务器 Provider 会话属 M5。" + }, + { + "path": "build-provider.plugin.json", + "category": "valid-manifest", + "outcome": "partial-support", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:128 build_fixtures_validate", + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens", + "rust/lithe-core/src/tests/build_systems.rs:1115 provider_processes_are_install_review_programs", + "rust/lithe-core/src/tests/declarative_plugins.rs:445 build_providers_require_their_own_host_capability", + "rust/lithe-core/src/tests/marketplace_plugins.rs:219 install_review_fixtures_match_golden_outcomes", + "rust/lithe-core/src/tests/marketplace_plugins.rs:332 build_provider_versions_require_host_support_before_download" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections" + ], + "windowsStatus": "validated-only", + "gapStage": "M5", + "gapDetail": "Windows 只断言该清单被能力门控拒绝;自有进程 Provider 的启动、litheBuild 协商、会话回收没有任何宿主测试,属 M5。" + }, + { + "path": "build-static-file.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:128 build_fixtures_validate", + "rust/lithe-core/src/tests/build_systems.rs:143 build_plans_match_the_contract_goldens", + "rust/lithe-core/src/tests/build_systems.rs:218 cargo_run_entries_match_the_builtin_detector", + "rust/lithe-core/src/tests/build_systems.rs:361 detection_applies_nesting_priority_and_rejections", + "rust/lithe-core/src/tests/build_systems.rs:1236 static_models_and_sync_fingerprints_reject_escaping_symlinks", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (33 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections" + ], + "windowsStatus": "validated-only", + "gapStage": "M4", + "gapDetail": "Windows 只有清单验收与能力门控;static 模型检测、选项、任务、同步指纹无宿主接线测试,属声明式构建接入。" + }, + { + "path": "build-tool-path.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/build_systems.rs:1599 explicit_build_tool_path_requires_supported_global_file_setting", + "rust/lithe-core/src/tests/build_systems.rs:1666 executable_candidates_order_and_constraints_are_shared_and_deterministic", + "rust/lithe-core/src/tests/build_systems.rs:1719 published_build_v2_paths_adapt_to_candidates_and_preserve_invalid_values", + "rust/lithe-core/src/tests/build_systems.rs:1755 executable_resolution_is_opt_in_and_custom_runtimes_share_the_same_plan" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "M4", + "gapDetail": "Windows 侧零引用:不在合法/门控清单,也没有任何宿主测试读取;executableResolution:1 候选顺序与显式路径语义属 M4 接入。" + }, + { + "path": "command-plans.json", + "category": "command-plans", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:212 command_plan_fixtures_match_golden_expansions" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M5", + "gapDetail": "Windows 经 Core 命令消费展开结果;bundled_php_contract_package_expands_plans 实测了 runPlan/testPlan 展开但用的是 PHP 契约包而非本 golden。发现/测试/launchPlan 作用域的宿主行为测试属 M5。" + }, + { + "path": "function-protocol.json", + "category": "function-protocol", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/lsp/interface/plugin_function.rs:558 the_offer_matches_the_fixture", + "rust/lithe-core/src/lsp/interface/plugin_function.rs:648 outcomes_follow_the_fixture", + "rust/lithe-core/src/lsp/interface/plugin_function.rs:703 host_requests_follow_the_fixture (protocol v2)" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M6", + "gapDetail": "Windows 网页桥 v0 对 functions.* 返回 plugin.web.unknownMethod(web.rs 已知边界);函数列表/调用/取消/进度的宿主接线属 M6。" + }, + { + "path": "hello-lithe.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:65 contract_fixtures_and_official_plugins_validate", + "rust/lithe-core/src/tests/declarative_plugins.rs:532 ui_contributions_are_normalized_without_interpreting_text", + "rust/lithe-core/src/tests/declarative_plugins.rs:610 ui_icon_declarations_validate_and_preserve_theme_resources", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (12 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/web.rs:1657 text_window_is_literal_and_hello_fixture_is_registered", + "windows/tauri/src/features/plugins/declarative-plugin-ui.test.ts:33 hello ui1 contributes a text window and all three command placements and revokes them" + ], + "windowsStatus": "wired", + "gapStage": null, + "gapDetail": null + }, + { + "path": "invalid-manifests.json", + "category": "invalid-edits", + "outcome": "rejection", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:80 invalid_manifest_fixtures_keep_their_stable_codes" + ], + "windowsStatus": "wired", + "gapStage": null, + "gapDetail": null + }, + { + "path": "language-host-integration.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:65 contract_fixtures_and_official_plugins_validate", + "rust/lithe-core/src/tests/declarative_plugins.rs:152 host_integration_capabilities_are_normalized_and_checked_against_the_integration", + "rust/lithe-core/src/tests/declarative_plugins.rs:276 runtime_launch_and_initialization_options_are_reviewed_programs", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (13 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities" + ], + "windowsStatus": "validated-only", + "gapStage": "M10", + "gapDetail": "Windows 只做清单验收;jdt 集成的启动计划解析、必需包资源校验与真实包接线没有 fixture 级宿主测试(launch.rs 测试使用 language-packaged-server),真实 JDT 包验收随 M10 第一阶段。" + }, + { + "path": "language-packaged-server.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:65 contract_fixtures_and_official_plugins_validate", + "rust/lithe-core/src/tests/declarative_plugins.rs:81 validation_normalizes_order_and_spelling_without_touching_arguments", + "rust/lithe-core/src/tests/declarative_plugins.rs:251 api_level_1_language_server_arguments_stay_literal", + "rust/lithe-core/src/tests/marketplace_plugins.rs:219 install_review_fixtures_match_golden_outcomes", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (23 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:162 scan_accepts_valid_packages_and_quarantines_broken_ones", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:196 store_problems_quarantine_packages_with_valid_manifests", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:258 catalog_build_combines_bundled_and_installed_sources", + "windows/tauri/src-tauri/src/plugin_host/install_tests.rs:43 directory_snapshot_copies_contents", + "windows/tauri/src-tauri/src/plugin_host/install_tests.rs:118 staging_install_replaces_version_and_rejects_broken_manifests", + "windows/tauri/src-tauri/src/plugin_host/install_tests.rs:185 rollback_picks_the_highest_previous_version", + "windows/tauri/src-tauri/src/plugin_host/launch.rs:933 resolve_launch_consumes_the_real_core_response_envelope" + ], + "windowsStatus": "wired", + "gapStage": null, + "gapDetail": null + }, + { + "path": "language-system-command.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:65 contract_fixtures_and_official_plugins_validate", + "rust/lithe-core/src/tests/declarative_plugins.rs:106 validation_keeps_the_leading_dot_of_hidden_file_names", + "rust/lithe-core/src/tests/declarative_plugins.rs:330 validation_probe_cannot_use_settings", + "rust/lithe-core/src/tests/marketplace_plugins.rs:219 install_review_fixtures_match_golden_outcomes", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (1 case uses this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:258 catalog_build_combines_bundled_and_installed_sources" + ], + "windowsStatus": "validated-only", + "gapStage": "M10", + "gapDetail": "Windows 覆盖清单验收与 bundled catalog 扫描,但 systemCommand 启动(PATH/工具发现解析、LITHE_*_PATH 导出)没有 fixture 级宿主测试;真实 systemCommand 插件(gopls/intelephense)验收随 M10 第一阶段。macOS 侧同样没有直接引用。" + }, + { + "path": "marketplace/index-cases.json", + "category": "marketplace", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/marketplace_plugins.rs:100 index_fixture_cases_match_expected_availability" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M2", + "gapDetail": "Windows 声明式市场宿主未接(M2);当前无任何 Windows 测试消费可用性判定结果。" + }, + { + "path": "marketplace/index.json", + "category": "marketplace", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/marketplace_plugins.rs:100 index_fixture_cases_match_expected_availability", + "rust/lithe-core/src/tests/marketplace_plugins.rs:131 accepted_entries_are_normalized_newest_first", + "rust/lithe-core/src/tests/marketplace_plugins.rs:272 ui_api_availability_requires_an_implemented_platform_host", + "rust/lithe-core/src/tests/marketplace_plugins.rs:332 build_provider_versions_require_host_support_before_download" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M2", + "gapDetail": "Windows 声明式市场宿主未接(M2);索引归一化与可用性只经 Core 命令验证。" + }, + { + "path": "marketplace/install-reviews.json", + "category": "marketplace", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/marketplace_plugins.rs:219 install_review_fixtures_match_golden_outcomes" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M2", + "gapDetail": "Windows 磁盘安装链走 installReview 命令但测试用 language-packaged-server 清单而非本 golden 审查矩阵;完整来源/审批记录接入属 M2。" + }, + { + "path": "marketplace/invalid-index-entries.json", + "category": "marketplace", + "outcome": "rejection", + "coreEvidence": [ + "rust/lithe-core/src/tests/marketplace_plugins.rs:147 invalid_index_entries_report_their_stable_codes" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M2", + "gapDetail": "Windows 无市场索引消费路径(M2);拒绝码一致性的宿主断言未建立。" + }, + { + "path": "marketplace/source-urls.json", + "category": "marketplace", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/marketplace_plugins.rs:203 source_url_fixtures_resolve_to_expected_index_urls" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M2", + "gapDetail": "Windows 无源解析入口(M2)。" + }, + { + "path": "native-controls.plugin.json", + "category": "native-ui", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:827 native_public_controls_validate_types_ranges_info_and_buttons" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "M7", + "gapDetail": "Windows 侧零引用;ui:3 原生控件渲染未声明也未实现,不能用网页替代。" + }, + { + "path": "native-form.plugin.json", + "category": "native-ui", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:721 native_ui_forms_validate_controls_values_actions_and_budgets", + "rust/lithe-core/src/tests/declarative_plugins.rs:785 native_ui_form_grid_validates_columns_spans_and_scope" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "M7", + "gapDetail": "Windows 侧零引用;表单控件、值恢复与提交语义属 ui:3(M7)。" + }, + { + "path": "native-panel.plugin.json", + "category": "native-ui", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:666 native_ui_validates_version_ownership_actions_and_budgets", + "rust/lithe-core/src/tests/declarative_plugins.rs:785 native_ui_form_grid_validates_columns_spans_and_scope" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "M7", + "gapDetail": "Windows 侧零引用;原生树/列表/工具栏与状态恢复属 ui:3(M7)。" + }, + { + "path": "node-runtime.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:65 contract_fixtures_and_official_plugins_validate", + "rust/lithe-core/src/tests/declarative_plugins.rs:276 runtime_launch_and_initialization_options_are_reviewed_programs", + "rust/lithe-core/src/tests/declarative_plugins.rs:351 settings_are_normalized_in_identifier_order", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (24 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities" + ], + "windowsStatus": "validated-only", + "gapStage": "M10", + "gapDetail": "Windows 只做清单验收;runtime 启动(packagedRuntime/minimumVersion 探测)与设置解析的 fixture 级宿主测试缺失(PHP 契约包端到端覆盖了 runtime node 计划展开,但不是本清单),真实插件验收随 M10 第一阶段。" + }, + { + "path": "project-generator-templates/.gitattributes", + "category": "template-assets", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/project_generators.rs:65 golden_project_plans_match (plan.rs collect_files walks the templates directory)" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M8", + "gapDetail": "模板字节稳定性标记(* -text),经 plan.rs 递归读取被 Core golden 测试消费;Windows 归档解压/模板生成接入属 M8。" + }, + { + "path": "project-generator-templates/app/README.md", + "category": "template-assets", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/project_generators.rs:65 golden_project_plans_match (plan.rs collect_files walks the templates directory)" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M8", + "gapDetail": "files 步骤模板,经 plan.rs 递归读取被 Core golden 测试消费;Windows 生成链路接入属 M8。" + }, + { + "path": "project-generator-templates/app/dot-gitignore", + "category": "template-assets", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/project_generators.rs:65 golden_project_plans_match (plan.rs collect_files walks the templates directory)" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M8", + "gapDetail": "files 步骤模板(dot-gitignore 命名规避 git 忽略),经 plan.rs 递归读取被 Core golden 测试消费;Windows 生成链路接入属 M8。" + }, + { + "path": "project-generator-templates/main/Main.java", + "category": "template-assets", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/project_generators.rs:65 golden_project_plans_match (plan.rs collect_files walks the templates directory)" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M8", + "gapDetail": "files 步骤模板(Main.java 带条件占位符),经 plan.rs 递归读取被 Core golden 测试消费;Windows 生成链路接入属 M8。" + }, + { + "path": "project-generator.plugin.json", + "category": "project-generation", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/project_generators.rs:65 golden_project_plans_match", + "rust/lithe-core/src/tests/project_generators.rs:92 catalog_lists_generators_with_requirement_providers", + "rust/lithe-core/src/tests/project_generators.rs:259 rejects_invalid_generator_declarations", + "rust/lithe-core/src/tests/project_generators.rs:327 project_generators_need_api_level_3", + "rust/lithe-core/src/tests/project_generators.rs:339 generator_commands_require_install_review_again" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "M8", + "gapDetail": "Windows 侧零引用;新建项目窗口、生成计划与 staging 清理未接入。" + }, + { + "path": "project-plans.json", + "category": "project-plans", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/project_generators.rs:65 golden_project_plans_match" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M8", + "gapDetail": "Windows 经 Core 命令消费生成计划;宿主生成协调器与失败清理测试属 M8。" + }, + { + "path": "runtime-versions.json", + "category": "runtime-versions", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:312 runtime_version_fixtures_match_golden_outcomes" + ], + "windowsEvidence": [], + "windowsStatus": "indirect-core", + "gapStage": "M10", + "gapDetail": "Windows launch.rs 用 node/java/go 真实输出格式自测版本解析,未读取本 golden;建议接入宿主探测路径时用本 fixture 对齐,真实运行时验收随 M10。" + }, + { + "path": "settings-v2.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:901 immediate_settings_must_be_used_by_workspace_configuration", + "rust/lithe-core/src/tests/declarative_plugins.rs:911 settings_v2_manifests_validate_and_resolve_by_layer", + "rust/lithe-core/src/tests/declarative_plugins.rs:958 settings_v2_rejects_unsafe_setting_use" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections" + ], + "windowsStatus": "validated-only", + "gapStage": "M4", + "gapDetail": "settings 2/languageServers 2 已在宿主能力声明内,但该 fixture 同时 requires buildSystems/buildProviders,v0 宿主因此整体门控(tests.rs:149 的注释把门控原因写成'settings 2 与 languageServers 2 未声明',与 host_capabilities 实际声明不符,属过时注释)。注册设置页、settingActions 与分层覆盖的宿主行为测试缺 settings-v2 fixture 级覆盖(M4/M6 补齐;现有覆盖走声明式设置编辑器与 settings_tests)。" + }, + { + "path": "signature-vectors.json", + "category": "signature-vectors", + "outcome": "success", + "coreEvidence": [], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/signature.rs shared_fixture_verifies_end_to_end" + ], + "windowsStatus": "validated-only", + "gapStage": "M1", + "gapDetail": "共享公开向量已被 Rust 签名验证测试消费;Swift 向量测试等待 macOS 执行,签名包的完整安装审批、失败恢复及回滚生产链仍由 M1 修复任务验证。" + }, + { + "path": "vcs-provider.plugin.json", + "category": "vcs-provider", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/vcs.rs:65 the_provider_fixture_validates_and_is_reviewed_with_its_tool", + "rust/lithe-core/src/tests/vcs.rs:94 provider_manifests_state_permissions_activation_and_requirements", + "rust/lithe-core/src/tests/vcs.rs:147 a_launch_plan_names_the_vcs_provider_it_starts", + "rust/lithe-core/src/tests/vcs.rs:196 root_detection_matches_the_contract_cases" + ], + "windowsEvidence": [], + "windowsStatus": "unwired", + "gapStage": "M9", + "gapDetail": "Windows 侧零引用;VCS Provider 会话、根识别与宿主入口未接入。macOS 宿主已声明 vcsProviders:[1](PluginTypes.swift:502)且矩阵 vcs-subversion-and-plugin-providers 记 macOS partial/验证 pending(VcsProviderHostTests 覆盖根识别、监听与生命周期自动化);对齐 macOS 已有子能力属 M9。" + }, + { + "path": "web-panel.plugin.json", + "category": "valid-manifest", + "outcome": "success", + "coreEvidence": [ + "rust/lithe-core/src/tests/declarative_plugins.rs:65 contract_fixtures_and_official_plugins_validate", + "rust/lithe-core/src/tests/declarative_plugins.rs:563 web_tool_windows_need_ui_2_their_permission_and_a_package_page", + "rust/lithe-core/src/tests/declarative_plugins.rs:130 invalid_manifest_fixtures_report_their_stable_codes (8 cases use this base)" + ], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities" + ], + "windowsStatus": "validated-only", + "gapStage": "M6", + "gapDetail": "Windows 宿主已声明 ui:[1,2] 并具备 Web 工具窗口链路(web.rs 机制测试),但本 fixture 声明的包页面/入口没有被任何宿主测试打开;build.*/functions.* 桥方法按 v0 边界拒绝,接入属 M4/M6。" + } + ] +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 3cf6a4b511..3bb14a6bca 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -13,6 +13,7 @@ This directory is the cross-platform source of truth: | `plugin-manifest.schema.json` | JSON shape of `plugin.json` | | `fixtures/*.plugin.json` | Valid example manifests, named after the capability they exercise rather than a published plugin: `language-*` (packaged, system-command, and host-integration language servers; `node-runtime` for a discovered runtime), `hello-lithe` and `web-panel` (UI), `settings-v2`, and `build-*` (level 3 build systems and the two forms of a build provider) | | `fixtures/invalid-manifests.json` | Edits to a valid manifest and the rejection code every host must report | +| `fixtures/signature-vectors.json` | Public cross-platform vectors for the package publisher signature: the RFC 8032 test key, a fixed package with non-ASCII file names, its canonical payload, deterministic signature, and content digest. Both platform verifiers must reproduce the same answers | | `fixtures/command-plans.json` | Golden run/test command expansions and language-server launch plans | | `fixtures/runtime-versions.json` | Golden outcomes of the launch runtime version check | | `fixtures/build-plans.json` | Golden build-system plans and decoded outputs | diff --git a/shared/contracts/plugins/v1/fixtures/signature-vectors.json b/shared/contracts/plugins/v1/fixtures/signature-vectors.json new file mode 100644 index 0000000000..4c47bfd36b --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/signature-vectors.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "description": "Public cross-platform test vectors for the plugin package signature (macOS PluginPackageSignature and the Windows plugin_host signature module). The Ed25519 key is the RFC 8032 test key; it must never sign real packages. Regenerating requires updating both platform tests in the same change.", + "ed25519": { + "seedHex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "publicKeyHex": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "messageEmptySignatureHex": "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" + }, + "packageFiles": { + "plugin.json": "plugin manifest bytes", + "language-server/bin/server.js": "console.log('server');\n", + "插件/服务器.js": "console.log('非 ASCII 路径');\n", + "日本語/README.md": "# サンプル\n", + "assets/заметки.txt": "заметки\n" + }, + "canonicalPayload": { + "keyID": "lithe-test-v1", + "pluginID": "dev.example.plugin", + "pluginVersion": "0.3.0", + "files": { + "plugin.json": "1ec2dc0f2dacb3b0c43dd9dc6035731249094d080f60394236f68450420e6a29", + "language-server/bin/server.js": "fca7a5298331fc1e306876075edf8f47b791b200b182c841dc250c54d5138760", + "插件/服务器.js": "481e4738eca92d68a48ef69591c5ecb0afd2529a0eba37b489665cd31b457ad5", + "日本語/README.md": "667ec9c9c320a9dee9f64ce35daf3ec27cf5bc06aaa0625253c788301bd5f7af", + "assets/заметки.txt": "d303fa2a84fb9a7d0c0e50cf1c27ff33f8c90cb53d366138929934f4e2e758fb" + }, + "payloadBase64": "c2NoZW1hVmVyc2lvbj0xCmFsZ29yaXRobT1lZDI1NTE5CmtleUlEPWxpdGhlLXRlc3QtdjEKcGx1Z2luSUQ9WkdWMkxtVjRZVzF3YkdVdWNHeDFaMmx1CnBsdWdpblZlcnNpb249TUM0ekxqQQpmaWxlPVlYTnpaWFJ6TDlDMzBMRFF2TkMxMFlMUXV0QzRMblI0ZEE9ZDMwM2ZhMmE4NGZiOWE3ZDBjMGU1MGNmMWMyN2ZmMzNmOGM5MGNiNTNkMzY2MTM4OTI5OTM0ZjRlMmU3NThmYgpmaWxlPWJHRnVaM1ZoWjJVdGMyVnlkbVZ5TDJKcGJpOXpaWEoyWlhJdWFuTT1mY2E3YTUyOTgzMzFmYzFlMzA2ODc2MDc1ZWRmOGY0N2I3OTFiMjAwYjE4MmM4NDFkYzI1MGM1NGQ1MTM4NzYwCmZpbGU9Y0d4MVoybHVMbXB6YjI0PTFlYzJkYzBmMmRhY2IzYjBjNDNkZDlkYzYwMzU3MzEyNDkwOTRkMDgwZjYwMzk0MjM2ZjY4NDUwNDIwZTZhMjkKZmlsZT01by1TNUx1MkwtYWNqZVdLb2VXWnFDNXFjdz00ODFlNDczOGVjYTkyZDY4YTQ4ZWY2OTU5MWM1ZWNiMGFmZDI1MjlhMGViYTM3YjQ4OTY2NWNkMzFiNDU3YWQ1CmZpbGU9NXBlbDVweXM2S3FlTDFKRlFVUk5SUzV0WkE9NjY3ZWM5YzljMzIwYTlkZWU5ZjY0Y2UzNWRhZjNlYzI3Y2Y1YmMwNmFhYTA2MjUyNTNjNzg4MzAxYmQ1ZjdhZgo=", + "signatureBase64": "lFd79pLu83LDn4D8N0cSAtm3jzHGNLIVGBAfS0v4Wzfnim4DLcCOKITqS8v5iOGP951whGhZIZxqdMZULHy+Cw==" + }, + "contentDigest": { + "files": { + "plugin.json": "1ec2dc0f2dacb3b0c43dd9dc6035731249094d080f60394236f68450420e6a29", + "language-server/bin/server.js": "fca7a5298331fc1e306876075edf8f47b791b200b182c841dc250c54d5138760", + "插件/服务器.js": "481e4738eca92d68a48ef69591c5ecb0afd2529a0eba37b489665cd31b457ad5", + "日本語/README.md": "667ec9c9c320a9dee9f64ce35daf3ec27cf5bc06aaa0625253c788301bd5f7af", + "assets/заметки.txt": "d303fa2a84fb9a7d0c0e50cf1c27ff33f8c90cb53d366138929934f4e2e758fb" + }, + "digest": "4dcdacc3e80f23932029638487a3074301d95a6721151a03a010d8633fe55a61" + } +} diff --git a/shared/platform-feature-matrix/features/declarative-plugin-contract.json b/shared/platform-feature-matrix/features/declarative-plugin-contract.json index a23d456e39..5ccd10e685 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-contract.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-contract.json @@ -7,6 +7,8 @@ "evidence": [ "shared/contracts/plugins/v1", "rust/lithe-core/src/plugins/declarative.rs", + "macos/Tests/LitheTests/PluginSignatureVectorTests.swift", + "shared/contracts/plugins/v1/fixtures/signature-vectors.json", "macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginStartupLoader.swift", @@ -31,6 +33,9 @@ "evidence": [ "windows/tauri/src-tauri/src/plugin_host/mod.rs", "windows/tauri/src-tauri/src/plugin_host/store.rs", + "shared/contracts/plugins/v1/fixtures/signature-vectors.json", + "windows/tauri/src-tauri/src/plugin_host/install_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/signature.rs", "windows/tauri/src-tauri/src/plugin_host/scan.rs", "windows/tauri/src-tauri/src/plugin_host/launch.rs", "windows/tauri/src-tauri/src/plugin_host/tests.rs", @@ -54,6 +59,6 @@ "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:[1,2], serverProcess:1, settings:[1,2], ui:[1,2]},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。 后续安全修复已补插件身份传递、运行中和待启动会话排空、跨窗口包变更确认、完整启动前检查及 ui 1 文本/命令入口;目前 WebView 仍为独立窗口,停靠位置、图标、动作状态及布局持久化未完成。", + "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:[1,2], serverProcess:1, settings:[1,2], ui:[1,2]},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。 后续安全修复已补插件身份传递、运行中和待启动会话排空、跨窗口包变更确认、完整启动前检查及 ui 1 文本/命令入口;目前 WebView 仍为独立窗口,停靠位置、图标、动作状态及布局持久化未完成。 M1 信任链已接入:windows/tauri/src-tauri/src/plugin_host/signature.rs 与 macOS PluginPackageSignature 字节兼容(canonical payload、UTF-8 base64url token、按原始路径排序、每文件 SHA-256、contentDigest),使用成熟的 ed25519-dalek 校验固定官方公钥(lithe-official-plugins-v2);官方 ID 信任策略由宿主固定(php/java-support=sameTeamAsHost、go-support=publisherPackage),覆盖包自声明,sameTeamAsHost 仅信任 bundled 资源;签名文档存在即必须验证。安装记录升为 schema 2:origin、marketplace/disk provenance(全包内容摘要与批准程序)、当前/上一版本证据成一致事务;schema 1 旧记录不凭 enabled 伪造信任,扫描直接隔离并要求重新安装。安装确认、staging 发布前、启动扫描、运行/测试计划入口与回滚均校验完整文件集合与摘要;ZIP 大小写冲突条目、reparse point、路径逃逸与签名文档损坏均被拒绝。双端共享公开向量 shared/contracts/plugins/v1/fixtures/signature-vectors.json(RFC 8032 测试密钥锚点,非 ASCII 文件名覆盖),Rust 端经 shared_fixture_verifies_end_to_end 断言;Swift 端 PluginSignatureVectorTests 消费同一文件,macOS 实机运行待 CI。生产私钥不进入源码与测试。", "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移未完成,宿主 UI 仅完成文本和命令入口,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 仍缺停靠、图标和动作状态等对齐,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。 Windows 安全回归:plugin_host::launch 的版本探测必须在父进程退出、后代继承管道时仍有界结束并清理进程树;web 回归验证真实文档来源与工作区文件目标。 Windows 语言资源整合:声明式插件的运行分组、管理页与安装审查使用共享生成词条;中英文回归验证安装名称/版本、程序数量和启停标签,并确认用户值中的占位符保持字面文本。 Windows 宿主修复回归:在 Core startServer 尚未返回时禁用插件,晚到会话必须先停止再拒绝;即时设置携带 pluginId,Core 接受并下推对应会话。缺失 requiredPackagePaths/runtimeRequirements 或 validationArguments 非零退出时拒绝启动。Hello Lithe 文本窗口、菜单、命令面板和工具栏可见,禁用后全部撤销;停靠、图标、动作状态及持久布局仍待接入,保持 partial。" } diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 2508fb28c0..8958fd893e 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -991,6 +991,33 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.23.0" @@ -1316,6 +1343,30 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.18.0" @@ -1467,6 +1518,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -2896,6 +2953,8 @@ dependencies = [ "lithe-agent-host", "lithe-git-host", "lithe-ide-host", + "lithe-remote-client", + "lithe-remote-protocol", "quick-xml 0.37.5", "rand 0.8.7", "regex", @@ -2955,6 +3014,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "lithe-remote-client" +version = "0.1.0" +dependencies = [ + "lithe-remote-protocol", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "lithe-remote-protocol" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + [[package]] name = "lithe-terminal" version = "0.1.0" @@ -2976,6 +3054,7 @@ version = "0.3.0" dependencies = [ "base64 0.22.1", "chrono", + "ed25519-dalek", "http", "keyring", "lithe-agent-host", diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index bfb42251bb..9a284f628e 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ tauri-build = { version = "2", features = [] } [dependencies] base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } +ed25519-dalek = "2" lithe-agent-host = { path = "../../../rust/lithe-agent-host" } lithe-git-host = { path = "../../../rust/lithe-git-host" } lithe-core = { path = "../../../rust/lithe-core" } diff --git a/windows/tauri/src-tauri/src/plugin_host/install.rs b/windows/tauri/src-tauri/src/plugin_host/install.rs index c691c98275..e948c2c75b 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install.rs @@ -6,14 +6,16 @@ //! the input is copied to a private snapshot first, Core's //! `plugins.marketplace.installReview` produces the permission and program //! summary the user confirms, and installation re-validates the staged bytes -//! before they replace the active version. Package trust comes only from this -//! explicit review; there is no signature verification in v0 (recorded gap in -//! the declarative plugin host note). +//! before they replace the active version. Disk packages are community +//! origin: the installation record stores the content digest and the +//! approved programs of the confirmed review, and every later load rechecks +//! them. A publisher signature document is verified whenever one is present; +//! it never substitutes for the user's review. use super::scan::{core_execute, validate_manifest}; -use super::store::{self, InstallationRecord}; +use super::store::{self, DiskProvenance, InstallationRecord}; use super::{CatalogEntry, PluginHostState}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::io::Read; use std::path::{Path, PathBuf}; @@ -78,6 +80,15 @@ pub struct InstallReview { pub new_programs: Vec, } +/// Source facts recorded beside the snapshot, so the installation record can +/// name what the user installed without changing the confirm-call contract. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SnapshotSource { + file_name: String, + archive_sha256: Option, +} + /// Prepares a disk import from a ZIP archive or an unpacked directory. The /// source is copied to a private snapshot under the app's temporary /// directory; nothing is installed and nothing outside the snapshot is @@ -97,6 +108,16 @@ pub fn declarative_plugins_prepare_disk_install( let snapshot = temporary_root.join(&session_id); let source = PathBuf::from(&source_path); let result = (|| { + let file_name = source + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .ok_or("the source has no file name")?; + let archive_sha256 = + if source.extension().and_then(|extension| extension.to_str()) == Some("zip") { + Some(archive_digest(&source)?) + } else { + None + }; if source.extension().and_then(|extension| extension.to_str()) == Some("zip") { extract_zip_snapshot(&source, &snapshot)?; } else if source.is_dir() { @@ -107,9 +128,20 @@ pub fn declarative_plugins_prepare_disk_install( source.display() )); } - let manifest = read_snapshot_manifest(&snapshot)?; + let manifest = read_package_manifest(&snapshot)?; + // Source facts live beside the snapshot, not inside it: every file + // under the snapshot becomes an installed package file. + std::fs::write( + snapshot_source_path(&temporary_root, &session_id), + serde_json::to_string(&SnapshotSource { + file_name, + archive_sha256, + }) + .map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; Ok(PreparedInstall { - session_id, + session_id: session_id.clone(), review: review_snapshot(&manifest, None)?, manifest_digest: content_digest(&manifest), display_name: manifest["displayName"] @@ -119,7 +151,8 @@ pub fn declarative_plugins_prepare_disk_install( }) })(); if result.is_err() { - discard_snapshot(&snapshot); + discard_snapshot(&temporary_root.join(&session_id)); + discard_source_metadata(&temporary_root, &session_id); } result } @@ -127,23 +160,32 @@ pub fn declarative_plugins_prepare_disk_install( /// Installs a previously prepared snapshot after the user confirmed the /// review. The confirm call re-validates the snapshot bytes and re-runs the /// review, so a snapshot swapped between prepare and confirm is rejected. +/// The staged copy is re-verified against the recorded content digest before +/// it becomes the active version, and the installation record is written in +/// the same transaction as the version swap: a failure at any point leaves +/// the previous version and its record untouched. #[tauri::command] pub async fn declarative_plugins_confirm_disk_install( app: AppHandle, session_id: String, manifest_digest: String, ) -> Result { + let temporary_root = snapshot_root(&app)?; let snapshot = snapshot_path(&app, &session_id)?; + let source_record_path = snapshot_source_path(&temporary_root, &session_id); let result = async { - let manifest = read_snapshot_manifest(&snapshot)?; + let source: SnapshotSource = serde_json::from_str( + &std::fs::read_to_string(&source_record_path) + .map_err(|error| format!("the install session has no source record: {error}"))?, + ) + .map_err(|error| format!("the install session source record is unreadable: {error}"))?; + let manifest = read_package_manifest(&snapshot)?; if content_digest(&manifest) != manifest_digest { return Err("the package changed after review; the import was cancelled".to_string()); } // Re-review with no previously approved programs: a disk install always // requires a fresh full confirmation, matching the macOS rule. - let _ = review_snapshot(&manifest, None)?; - - let install_root = store::install_root(&app)?; + let review = review_snapshot(&manifest, None)?; let plugin_id = manifest["id"] .as_str() .ok_or("manifest has no id")? @@ -156,12 +198,83 @@ pub async fn declarative_plugins_confirm_disk_install( // Validate the final manifest against this host's capabilities before // anything is written into the install root. validate_manifest(&manifest).map_err(|rejection| rejection.message)?; + // Disk installs are community origin. The pinned trust policy still + // applies (an official ID cannot install as an unsigned community + // package), and a signature document present in the package must + // verify even though the community digest is the recorded evidence. + super::signature::verify_for_install( + &snapshot, + &plugin_id, + &version, + &manifest, + super::signature::InstallOrigin::Community, + super::signature::trusted_keys(), + ) + .map_err(|error| error.message())?; + let approved_digest = + super::signature::content_digest(&snapshot).map_err(|error| error.message())?; // A version switch invalidates the page's package bytes and bridge // authorization, so destroy all owned webviews before staging starts. let mutation = super::lifecycle::begin(&app, &plugin_id).await?; - staging_install(&install_root, &plugin_id, &version, &snapshot)?; - write_installation_record(&install_root, &plugin_id, &version)?; + let install_root = store::install_root(&app)?; + let existing = read_installation_record(&install_root, &plugin_id); + let replaces_active = existing + .as_ref() + .map(|record| record.version == version) + .unwrap_or(false); + // The same-version rule keeps the previous chain intact: replacing + // the active version in place does not overwrite what the user can + // still roll back to. + let (previous_version, previous_origin) = if replaces_active { + ( + existing + .as_ref() + .and_then(|record| record.previous_version.clone()), + existing.as_ref().and_then(|record| record.previous_origin), + ) + } else { + ( + existing.as_ref().map(|record| record.version.clone()), + existing.as_ref().and_then(|record| record.origin), + ) + }; + let (previous_marketplace, previous_disk) = if replaces_active { + ( + existing + .as_ref() + .and_then(|record| record.previous_marketplace.clone()), + existing + .as_ref() + .and_then(|record| record.previous_disk.clone()), + ) + } else { + ( + existing + .as_ref() + .and_then(|record| record.marketplace.clone()), + existing.as_ref().and_then(|record| record.disk.clone()), + ) + }; + let record = InstallationRecord { + schema_version: 2, + plugin_id: plugin_id.clone(), + version: version.clone(), + previous_version, + enabled: true, + origin: Some(super::signature::InstallOrigin::Community), + previous_origin, + marketplace: None, + previous_marketplace, + disk: Some(DiskProvenance { + file_name: source.file_name, + archive_sha256: source.archive_sha256, + content_digest: approved_digest, + approved_programs: canonical_programs(&review.programs), + }), + previous_disk, + }; + publish_installed_version(&install_root, &plugin_id, &version, &snapshot, record)?; rescan_after_change(&app)?; mutation.complete(true); Ok(catalog_entry_of(&app, &plugin_id) @@ -169,6 +282,7 @@ pub async fn declarative_plugins_confirm_disk_install( } .await; discard_snapshot(&snapshot); + discard_source_metadata(&temporary_root, &session_id); result } @@ -178,10 +292,12 @@ pub fn declarative_plugins_cancel_disk_install( app: AppHandle, session_id: String, ) -> Result<(), String> { + let temporary_root = snapshot_root(&app)?; let snapshot = snapshot_path(&app, &session_id)?; if snapshot.exists() { std::fs::remove_dir_all(snapshot).map_err(|error| error.to_string())?; } + discard_source_metadata(&temporary_root, &session_id); Ok(()) } @@ -193,12 +309,23 @@ fn snapshot_path(app: &AppHandle, session_id: &str) -> Result { { return Err("invalid install session".to_string()); } + Ok(snapshot_root(app)?.join(session_id)) +} + +fn snapshot_root(app: &AppHandle) -> Result { Ok(app .path() .app_local_data_dir() .map_err(|error| error.to_string())? - .join("plugin-temp") - .join(session_id)) + .join("plugin-temp")) +} + +fn snapshot_source_path(temporary_root: &Path, session_id: &str) -> PathBuf { + temporary_root.join(format!("{session_id}.source.json")) +} + +fn discard_source_metadata(temporary_root: &Path, session_id: &str) { + let _ = std::fs::remove_file(snapshot_source_path(temporary_root, session_id)); } /// Removes an installed package. Bundled packages cannot be uninstalled. @@ -255,9 +382,13 @@ pub async fn declarative_plugins_set_enabled( Ok(()) } -/// Restores the previous version directory of an installed package. Rollback -/// requires a retained previous version and always re-enables the package so -/// a broken update cannot leave it disabled and forgotten. +/// Restores the previous version of an installed package. The restored +/// package is verified with its own recorded provenance — a community +/// version is rechecked against the digest approved for it, and a signature +/// document present in it must still verify — before the record swaps, so a +/// rollback can never activate bytes the host would refuse to load. +/// Rollback requires a retained previous version and always re-enables the +/// package so a broken update cannot leave it disabled and forgotten. #[tauri::command] pub async fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> Result<(), String> { let install_root = store::install_root(&app)?; @@ -267,33 +398,49 @@ pub async fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> .map_err(|error| format!("{plugin_id} has no readable install record: {error}"))?, ) .map_err(|error| error.to_string())?; - let mut versions: Vec = std::fs::read_dir(&plugin_directory) - .map_err(|error| error.to_string())? - .flatten() - .filter_map(|entry| { - let name = entry.file_name().to_string_lossy().to_string(); - let path = entry.path(); - (path.is_dir() && path.join("plugin.json").is_file()).then_some(name) - }) - .collect(); - versions.sort_by(|left, right| store::compare_versions(left, right)); - versions.reverse(); - let Some(previous) = versions - .into_iter() - .find(|version| *version != record.version) - else { + let Some(previous) = record.previous_version.clone() else { return Err(format!( "{plugin_id} has no previous version to roll back to" )); }; - let mutation = super::lifecycle::begin(&app, &plugin_id).await?; - staging_install( - &install_root, + let Some(previous_origin) = record.previous_origin.or(record.origin) else { + return Err(format!( + "{plugin_id} has no recorded origin to verify the previous version with" + )); + }; + let previous_directory = plugin_directory.join(&previous); + let manifest = read_package_manifest(&previous_directory)?; + if manifest["id"].as_str() != Some(plugin_id.as_str()) + || manifest["version"].as_str() != Some(previous.as_str()) + { + return Err("the previous package does not match its installation record".to_string()); + } + validate_manifest(&manifest).map_err(|rejection| rejection.message)?; + super::signature::verify_for_origin( + &previous_directory, &plugin_id, &previous, - &plugin_directory.join(&previous), - )?; - write_installation_record(&install_root, &plugin_id, &previous)?; + &manifest, + previous_origin, + record.previous_content_digest(), + super::signature::trusted_keys(), + ) + .map_err(|error| error.message())?; + let mutation = super::lifecycle::begin(&app, &plugin_id).await?; + let swapped = InstallationRecord { + schema_version: 2, + plugin_id: record.plugin_id.clone(), + version: previous, + previous_version: Some(record.version.clone()), + enabled: true, + origin: record.previous_origin, + previous_origin: record.origin, + marketplace: record.previous_marketplace.clone(), + previous_marketplace: record.marketplace.clone(), + disk: record.previous_disk.clone(), + previous_disk: record.disk.clone(), + }; + write_installation_record(&install_root, &swapped)?; rescan_after_change(&app)?; mutation.complete(true); Ok(()) @@ -332,15 +479,17 @@ fn review_snapshot( }) } -/// Copies the snapshot into the install root, then validates the copied -/// manifest. Staging first keeps a failed copy from destroying the current -/// version: the active version directory is only replaced after the staged -/// copy validates. -pub(crate) fn staging_install( +/// Publishes a validated package as the active version of an installed +/// plugin. The staged copy is verified against the recorded provenance +/// before it replaces the active version, and the installation record is +/// written in the same transaction: a failure at any point restores the +/// previous version directory and leaves the old record readable. +pub(crate) fn publish_installed_version( install_root: &Path, plugin_id: &str, version: &str, source: &Path, + record: InstallationRecord, ) -> Result<(), String> { let plugin_directory = install_root.join(plugin_id); std::fs::create_dir_all(&plugin_directory).map_err(|error| error.to_string())?; @@ -349,21 +498,49 @@ pub(crate) fn staging_install( if staging_directory.exists() { std::fs::remove_dir_all(&staging_directory).map_err(|error| error.to_string())?; } - copy_directory_snapshot(source, &staging_directory)?; - let result = staging_validate_and_activate(&staging_directory, &version_directory); + let result = (|| { + copy_directory_snapshot(source, &staging_directory)?; + // The staged bytes are what gets installed: re-validate the copy, + // then bind it to the digest recorded for the approved snapshot. + let staged_manifest = read_package_manifest(&staging_directory)?; + validate_manifest(&staged_manifest).map_err(|rejection| rejection.message)?; + let origin = record + .origin + .ok_or("the installation record has no origin")?; + super::signature::verify_for_install( + &staging_directory, + plugin_id, + version, + &staged_manifest, + origin, + super::signature::trusted_keys(), + ) + .map_err(|error| error.message())?; + if let Some(expected) = record.active_content_digest() { + let staged_digest = super::signature::content_digest(&staging_directory) + .map_err(|error| error.message())?; + if staged_digest != expected { + return Err(super::signature::TrustError::ContentChanged.message()); + } + } + activate_staged_version(&staging_directory, &version_directory)?; + // The record only ever points at bytes that are already in place; if + // this write fails, the backup restore below keeps the old version + // and the old record consistent. + write_installation_record(install_root, &record) + })(); if result.is_err() { - // A failed install never leaves staging bytes behind. let _ = std::fs::remove_dir_all(&staging_directory); } result } -fn staging_validate_and_activate( +/// Replaces `version_directory` with the staged copy, keeping a backup so a +/// failed rename never leaves the install record pointing at missing bytes. +fn activate_staged_version( staging_directory: &Path, version_directory: &Path, ) -> Result<(), String> { - let staged_manifest = read_snapshot_manifest(staging_directory)?; - validate_manifest(&staged_manifest).map_err(|rejection| rejection.message)?; if !version_directory.exists() { return std::fs::rename(staging_directory, version_directory) .map_err(|error| format!("activation failed: {error}")); @@ -389,30 +566,65 @@ fn staging_validate_and_activate( Ok(()) } +/// Reads the current install record of a plugin; `None` when the plugin has +/// no record yet or the record is unreadable, which a fresh install then +/// replaces with a complete record. +fn read_installation_record(install_root: &Path, plugin_id: &str) -> Option { + let text = + std::fs::read_to_string(install_root.join(plugin_id).join("installation.json")).ok()?; + serde_json::from_str(&text).ok() +} + +/// Writes an installation record through temp + rename so a crash never +/// leaves a truncated record. pub(crate) fn write_installation_record( install_root: &Path, - plugin_id: &str, - version: &str, + record: &InstallationRecord, ) -> Result<(), String> { - let record_path = install_root.join(plugin_id).join("installation.json"); - let record = InstallationRecord { - schema_version: 1, - plugin_id: plugin_id.to_string(), - version: version.to_string(), - enabled: true, - }; + let record_path = install_root + .join(&record.plugin_id) + .join("installation.json"); std::fs::create_dir_all(record_path.parent().ok_or("record path")?) .map_err(|error| error.to_string())?; + let temp_path = record_path.with_extension("json.tmp"); std::fs::write( - &record_path, - serde_json::to_string_pretty(&record).map_err(|error| error.to_string())?, + &temp_path, + serde_json::to_string_pretty(record).map_err(|error| error.to_string())?, ) .map_err(|error| error.to_string())?; + std::fs::rename(&temp_path, &record_path).map_err(|error| error.to_string())?; Ok(()) } -fn read_snapshot_manifest(snapshot: &Path) -> Result { - let text = std::fs::read_to_string(snapshot.join("plugin.json")) +/// Serializes every approved program of the review into its canonical JSON +/// text, the same evidence the macOS record keeps for re-approval checks. +fn canonical_programs(programs: &[serde_json::Value]) -> Vec { + programs + .iter() + .map(|program| serde_json::to_string(program).unwrap_or_default()) + .collect() +} + +/// SHA-256 of the source archive, recorded with the disk provenance. +fn archive_digest(source: &Path) -> Result { + let mut file = std::fs::File::open(source) + .map_err(|error| format!("{} could not be opened: {error}", source.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| format!("{} could not be read: {error}", source.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn read_package_manifest(package_root: &Path) -> Result { + let text = std::fs::read_to_string(package_root.join("plugin.json")) .map_err(|error| format!("the package has no readable plugin.json: {error}"))?; serde_json::from_str(&text).map_err(|error| format!("plugin.json is not valid JSON: {error}")) } @@ -530,6 +742,15 @@ fn extract_zip_snapshot_inner(source: &Path, snapshot: &Path) -> Result<(), Stri if let Some(parent) = target.parent() { std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } + // Windows paths are case-insensitive: two entries that differ only in + // case (or a duplicated name) would silently overwrite each other and + // break the approved file set, so the second one is a rejection. + if target.exists() { + return Err(format!( + "archive entry {} conflicts with an earlier entry", + name.display() + )); + } let mut output = std::fs::File::create(&target).map_err(|error| error.to_string())?; let declared_size = entry.size(); let copied = std::io::copy(&mut entry.by_ref().take(declared_size + 1), &mut output) @@ -544,7 +765,7 @@ fn extract_zip_snapshot_inner(source: &Path, snapshot: &Path) -> Result<(), Stri // A ZIP that unpacked into a single wrapper directory still yields a // package root containing plugin.json directly. unwrap_single_directory(snapshot); - read_snapshot_manifest(snapshot)?; + read_package_manifest(snapshot)?; Ok(()) } @@ -650,7 +871,13 @@ pub fn cleanup_stale_snapshots(app: &AppHandle) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with(SNAPSHOT_PREFIX) { - let _ = std::fs::remove_dir_all(entry.path()); + // Snapshot directories and their `.source.json` sidecar records + // share the session-id prefix. + if entry.path().is_dir() { + let _ = std::fs::remove_dir_all(entry.path()); + } else { + let _ = std::fs::remove_file(entry.path()); + } } } } diff --git a/windows/tauri/src-tauri/src/plugin_host/install_tests.rs b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs index 623b9069f9..df615e4abf 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install_tests.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs @@ -1,12 +1,15 @@ -/// M2 disk-import tests exercise the internal functions directly (the Tauri -/// commands are thin AppHandle wrappers): snapshot isolation, ZIP safety, -/// staging install, and rollback version selection. +//! M2 disk-import tests exercise the internal functions directly (the Tauri +//! commands are thin AppHandle wrappers): snapshot isolation, ZIP safety, +//! the staged publish transaction, and rollback verification. The M1 +//! trust-chain tests cover the same transaction against the recorded +//! content digest. + mod install_tests { use crate::plugin_host::install::{ - copy_directory_snapshot, extract_zip_snapshot, staging_install, write_installation_record, + copy_directory_snapshot, extract_zip_snapshot, publish_installed_version, }; - use crate::plugin_host::store; - use crate::plugin_host::store::InstallationRecord; + use crate::plugin_host::signature::{self, InstallOrigin}; + use crate::plugin_host::store::{self, DiskProvenance, InstallationRecord}; use crate::plugin_host::tests::read_fixture_for_tests; use std::path::PathBuf; @@ -23,10 +26,17 @@ mod install_tests { root } + const TEST_PLUGIN_ID: &str = "io.example.acme-php"; + fn package_directory(root: &std::path::Path, version: &str) -> PathBuf { let directory = root.join(format!("pkg-{version}")); std::fs::create_dir_all(&directory).expect("package dir"); let mut manifest = read_fixture_for_tests("language-packaged-server.plugin.json"); + // A community install requires a non-official ID and an unsigned + // trust declaration; the official-namespace fixture is reshaped the + // way a real disk package looks. + manifest["id"] = serde_json::json!(TEST_PLUGIN_ID); + manifest["vendor"]["signatureRequirement"] = serde_json::json!("none"); manifest["version"] = serde_json::json!(version); std::fs::write( directory.join("plugin.json"), @@ -39,6 +49,33 @@ mod install_tests { directory } + /// A schema 2 community record for `package_directory`, with the content + /// digest the real install flow records from the approved snapshot. + fn community_record( + plugin_id: &str, + version: &str, + package_directory: &std::path::Path, + ) -> InstallationRecord { + InstallationRecord { + schema_version: 2, + plugin_id: plugin_id.to_string(), + version: version.to_string(), + previous_version: None, + enabled: true, + origin: Some(InstallOrigin::Community), + previous_origin: None, + marketplace: None, + previous_marketplace: None, + disk: Some(DiskProvenance { + file_name: "plugin.zip".to_string(), + archive_sha256: None, + content_digest: signature::content_digest(package_directory).expect("digest"), + approved_programs: Vec::new(), + }), + previous_disk: None, + } + } + #[test] fn directory_snapshot_copies_contents() { let root = unique_temp_root("snapshot"); @@ -114,105 +151,279 @@ mod install_tests { let _ = std::fs::remove_dir_all(&root); } + /// Two entries that only differ in case (or a duplicated name) would + /// silently overwrite each other on Windows and break the approved file + /// set, so the second one is a rejection. + #[test] + fn zip_case_conflicting_entries_are_rejected() { + let root = unique_temp_root("zip-conflict"); + let zip_path = root.join("conflict.zip"); + let zip_file = std::fs::File::create(&zip_path).expect("zip file"); + let mut writer = zip::ZipWriter::new(zip_file); + let options: zip::write::SimpleFileOptions = Default::default(); + let mut manifest = read_fixture_for_tests("language-packaged-server.plugin.json"); + manifest["version"] = serde_json::json!("1.0.0"); + let manifest_text = serde_json::to_string_pretty(&manifest).expect("manifest text"); + for name in ["plugin.json", "Plugin.json"] { + writer.start_file(name, options).expect("entry"); + std::io::Write::write_all(&mut writer, manifest_text.as_bytes()).expect("entry bytes"); + } + writer.finish().expect("zip finish"); + let snapshot = root.join("snapshot"); + assert!(extract_zip_snapshot(&zip_path, &snapshot).is_err()); + let _ = std::fs::remove_dir_all(&root); + } + #[test] - fn staging_install_replaces_version_and_rejects_broken_manifests() { + fn publish_rejects_broken_manifests_and_keeps_the_active_version() { let root = unique_temp_root("staging"); let install_root = root.join("plugins"); let good = package_directory(&root, "1.0.0"); - staging_install(&install_root, "acme.php", "1.0.0", &good).expect("installs"); - assert!(install_root.join("acme.php/1.0.0/plugin.json").is_file()); - write_installation_record(&install_root, "acme.php", "1.0.0").expect("record"); + let record = community_record(TEST_PLUGIN_ID, "1.0.0", &good); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &good, + record.clone(), + ) + .expect("installs"); + assert!(install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/plugin.json") + .is_file()); // A broken package fails validation and leaves the active version. let broken = root.join("pkg-broken"); std::fs::create_dir_all(&broken).expect("broken dir"); std::fs::write(broken.join("plugin.json"), "{ not json").expect("broken manifest"); - assert!(staging_install(&install_root, "acme.php", "2.0.0", &broken).is_err()); - assert!(!install_root.join("acme.php/2.0.0").exists()); - assert!(install_root.join("acme.php/1.0.0/plugin.json").is_file()); + let broken_record = community_record(TEST_PLUGIN_ID, "2.0.0", &broken); + assert!(publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "2.0.0", + &broken, + broken_record, + ) + .is_err()); + assert!(!install_root.join(TEST_PLUGIN_ID).join("2.0.0").exists()); + assert!(install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/plugin.json") + .is_file()); // The staging directory is cleaned up on failure. - assert!(!install_root.join("acme.php/.staging-2.0.0").exists()); + assert!(!install_root + .join(TEST_PLUGIN_ID) + .join(".staging-2.0.0") + .exists()); // A successful upgrade keeps both versions for rollback. let upgrade = package_directory(&root, "2.0.0"); - staging_install(&install_root, "acme.php", "2.0.0", &upgrade).expect("upgrade"); - assert!(install_root.join("acme.php/1.0.0/plugin.json").is_file()); - assert!(install_root.join("acme.php/2.0.0/plugin.json").is_file()); + let upgrade_record = community_record(TEST_PLUGIN_ID, "2.0.0", &upgrade); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "2.0.0", + &upgrade, + upgrade_record, + ) + .expect("upgrade"); + assert!(install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/plugin.json") + .is_file()); + assert!(install_root + .join(TEST_PLUGIN_ID) + .join("2.0.0/plugin.json") + .is_file()); let _ = std::fs::remove_dir_all(&root); } + /// The staged copy must match the digest recorded for the approved + /// snapshot: a snapshot swapped between review and publish never becomes + /// the active version, and the old version stays intact. #[test] - fn same_version_activation_failure_restores_the_previous_directory() { - let root = unique_temp_root("staging-restore"); + fn publish_binds_the_staged_bytes_to_the_approved_digest() { + let root = unique_temp_root("staging-digest"); let install_root = root.join("plugins"); - let good = package_directory(&root, "1.0.0"); - staging_install(&install_root, "acme.php", "1.0.0", &good).expect("installs"); - let installed_marker = install_root.join("acme.php/1.0.0/marker.txt"); - std::fs::write(&installed_marker, b"original").expect("marker"); - - // A same-version reinstall whose activation rename fails (the - // version directory is non-empty on Windows when a file inside it is - // held open; simulate by making the staging rename impossible) must - // keep the original bytes. - let replacement = package_directory(&root, "1.0.0-alt"); - // Force failure: pre-create the staging path as a file so rename onto - // it cannot succeed after the backup move. - std::fs::create_dir_all(install_root.join("acme.php")).expect("plugin dir"); - std::fs::write(install_root.join("acme.php/.staging-1.0.0"), b"occupied").expect("blocker"); - // staging_install removes the blocking staging path first, so instead - // occupy the TARGET by opening it with a share lock is not testable - // portably; simulate rename failure by removing write permission is - // also platform-specific. The contract under test is the restore - // path: inject failure by making the staging directory disappear - // between validate and activate is not possible via this API, so we - // assert the restore path directly through the backup/restore - // sequence: rename current aside, fail the staging rename, restore. - let version_directory = install_root.join("acme.php").join("1.0.0"); - let backup_directory = version_directory.with_extension("previous-tmp"); - std::fs::rename(&version_directory, &backup_directory).expect("backup move"); - // Failing activation: staging path does not exist. - assert!(std::fs::rename(root.join("pkg-nonexistent"), &version_directory).is_err()); - std::fs::rename(&backup_directory, &version_directory).expect("restore move"); - assert!(installed_marker.exists(), "original bytes restored"); + let approved = package_directory(&root, "1.0.0"); + let record = community_record(TEST_PLUGIN_ID, "2.0.0", &approved); + + let tampered = package_directory(&root, "2.0.0"); + std::fs::write( + tampered.join("language-server/bin/intelephense.js"), + b"evil", + ) + .expect("tampered entry"); + assert!(publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "2.0.0", + &tampered, + record.clone(), + ) + .is_err()); + assert!(!install_root.join(TEST_PLUGIN_ID).join("2.0.0").exists()); + + // Publishing the approved bytes succeeds and the record carries the + // same digest the installed files recompute to. + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "2.0.0", + &approved, + record.clone(), + ) + .expect("installs"); + let stored: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(install_root.join(TEST_PLUGIN_ID).join("installation.json")) + .expect("record"), + ) + .expect("record parses"); + let installed_directory = install_root.join(TEST_PLUGIN_ID).join("2.0.0"); assert_eq!( - std::fs::read(&installed_marker).expect("marker bytes"), - b"original" + stored + .disk + .as_ref() + .expect("disk provenance") + .content_digest, + signature::content_digest(&installed_directory).expect("digest") ); let _ = std::fs::remove_dir_all(&root); } + /// A same-version reinstall of changed bytes is rejected: the record and + /// the version directory keep the originally approved state. #[test] - fn rollback_picks_the_highest_previous_version() { - // Mirrors the selection logic of declarative_plugins_rollback without - // an AppHandle: enumerate versions, pick highest != current. + fn same_version_reinstall_of_changed_bytes_is_rejected() { + let root = unique_temp_root("staging-same-version"); + let install_root = root.join("plugins"); + let approved = package_directory(&root, "1.0.0"); + let record = community_record(TEST_PLUGIN_ID, "1.0.0", &approved); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &approved, + record.clone(), + ) + .expect("installs"); + let marker = install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/language-server/bin/intelephense.js"); + assert_eq!(std::fs::read(&marker).expect("marker"), b"entry"); + + let replacement = package_directory(&root, "1.0.0-replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + assert!(publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + record, + ) + .is_err()); + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"entry", + "the active version keeps its approved bytes" + ); + assert!(!install_root + .join(TEST_PLUGIN_ID) + .join(".staging-1.0.0") + .exists()); + let _ = std::fs::remove_dir_all(&root); + } + + /// Rollback verification: the restored version is checked against its + /// own recorded digest before the record may swap. The production + /// command performs exactly this check on the previous version + /// directory before writing the swapped record. + #[test] + fn rollback_rejects_a_tampered_previous_version() { let root = unique_temp_root("rollback"); let install_root = root.join("plugins"); - for version in ["1.0.0", "1.5.0", "2.0.0"] { - let package = package_directory(&root, version); - staging_install(&install_root, "acme.php", version, &package).expect("installs"); - } - write_installation_record(&install_root, "acme.php", "2.0.0").expect("record"); - let plugin_directory = install_root.join("acme.php"); - let mut versions: Vec = std::fs::read_dir(&plugin_directory) - .expect("plugin dir") - .flatten() - .filter_map(|entry| { - let name = entry.file_name().to_string_lossy().to_string(); - let path = entry.path(); - (path.is_dir() && path.join("plugin.json").is_file()).then_some(name) - }) - .collect(); - versions.sort_by(|left, right| store::compare_versions(left, right)); - versions.reverse(); - let record: InstallationRecord = serde_json::from_str( - &std::fs::read_to_string(plugin_directory.join("installation.json")).expect("record"), + let first = package_directory(&root, "1.0.0"); + let first_digest = signature::content_digest(&first).expect("digest"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &first, + community_record(TEST_PLUGIN_ID, "1.0.0", &first), ) - .expect("record parses"); - let previous = versions - .into_iter() - .find(|version| *version != record.version) - .expect("previous version"); - assert_eq!(previous, "1.5.0"); + .expect("installs"); + + // Tampering with the previous version makes the rollback candidate + // unverifiable against its recorded digest. + let previous_directory = install_root.join(TEST_PLUGIN_ID).join("1.0.0"); + std::fs::write( + previous_directory.join("language-server/bin/intelephense.js"), + b"evil", + ) + .expect("tamper"); + let manifest_text = + std::fs::read_to_string(previous_directory.join("plugin.json")).expect("manifest"); + let manifest: serde_json::Value = serde_json::from_str(&manifest_text).expect("manifest"); + let error = signature::verify_for_origin( + &previous_directory, + TEST_PLUGIN_ID, + "1.0.0", + &manifest, + InstallOrigin::Community, + Some(first_digest.as_str()), + signature::trusted_keys(), + ) + .expect_err("tampered previous version must be refused"); + assert_eq!(error.code(), "plugin.trust.contentChanged"); + let _ = std::fs::remove_dir_all(&root); + } + + /// The record shape survives serde round-trips with all provenance + /// fields intact; the scan and launch paths rely on this. + #[test] + fn installation_record_round_trips_provenance() { + let root = unique_temp_root("record-shape"); + let package = package_directory(&root, "1.0.0"); + let record = community_record(TEST_PLUGIN_ID, "1.0.0", &package); + let text = serde_json::to_string_pretty(&record).expect("record text"); + let parsed: InstallationRecord = serde_json::from_str(&text).expect("record parses"); + assert_eq!(parsed.schema_version, 2); + assert_eq!(parsed.origin, Some(InstallOrigin::Community)); + assert_eq!( + parsed.active_content_digest(), + record.active_content_digest() + ); + // A schema 1 record decodes without provenance; discovery quarantines + // it instead of trusting the enabled flag. + let legacy: InstallationRecord = serde_json::from_str(&format!( + r#"{{"schemaVersion": 1, "pluginId": "{TEST_PLUGIN_ID}", "version": "1.0.0"}}"# + )) + .expect("legacy record parses"); + assert_eq!(legacy.origin, None); + assert_eq!(legacy.active_content_digest(), None); + let _ = std::fs::remove_dir_all(&root); + } + + /// The write helper produces an atomic record the discovery path reads + /// back with its provenance. + #[test] + fn record_write_is_readable_by_discovery() { + let root = unique_temp_root("record-write"); + let install_root = root.join("plugins"); + let package = package_directory(&root, "1.0.0"); + let record = community_record(TEST_PLUGIN_ID, "1.0.0", &package); + publish_installed_version(&install_root, TEST_PLUGIN_ID, "1.0.0", &package, record) + .expect("published"); + let discovered = store::discover_installed_in(&install_root).expect("discovery works"); + assert_eq!(discovered.len(), 1); + assert!(discovered[0].record_problem.is_none()); + assert_eq!(discovered[0].origin, Some(InstallOrigin::Community)); + assert!(discovered[0].content_digest.is_some()); let _ = std::fs::remove_dir_all(&root); } } diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs index b7dcebccb9..aea861013f 100644 --- a/windows/tauri/src-tauri/src/plugin_host/launch.rs +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -63,8 +63,7 @@ pub fn declarative_plugins_language_launch( let _native_launch = app .state::() .acquire(&package.entry.plugin_id)?; - let package = package_by_id(&app, &package.entry.plugin_id) - .ok_or("plugin was removed during launch resolution")?; + let package = verified_package_by_id(&app, &package.entry.plugin_id)?; let Some(language_id) = owning_language_id(&package, &file_path) else { return Ok(None); }; @@ -113,6 +112,35 @@ pub(crate) fn package_by_id(app: &AppHandle, plugin_id: &str) -> Option Result { + let package = package_by_id(app, plugin_id) + .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; + let trust = package + .trust + .as_ref() + .expect("accepted packages carry trust evidence"); + super::signature::verify_for_origin( + &package.root, + &package.entry.plugin_id, + &package.entry.version, + &package.manifest, + trust.origin, + trust.expected_content_digest.as_deref(), + super::signature::trusted_keys(), + ) + .map_err(|error| format!("{} trust check failed: {}", plugin_id, error.message()))?; + Ok(package) +} + /// Returns the enabled declarative plugin that owns a file, for the run and /// test entry points. Mirrors the launch path's ownership matching without /// expanding a plan. @@ -820,6 +848,7 @@ mod tests { }, root, manifest, + trust: None, } } diff --git a/windows/tauri/src-tauri/src/plugin_host/mod.rs b/windows/tauri/src-tauri/src/plugin_host/mod.rs index 6ca4a91922..4dbc22e3d5 100644 --- a/windows/tauri/src-tauri/src/plugin_host/mod.rs +++ b/windows/tauri/src-tauri/src/plugin_host/mod.rs @@ -16,6 +16,7 @@ pub mod lifecycle; pub mod run_plans; pub mod scan; pub mod settings; +pub mod signature; pub mod store; #[cfg(test)] mod tests; @@ -108,6 +109,19 @@ pub struct ScannedPackage { pub root: PathBuf, /// Raw manifest; plan commands are pure functions and take it every time. pub manifest: Value, + /// Trust evidence the launch path re-verifies against the live bytes; + /// `None` for quarantined packages, which never reach a launch. + pub trust: Option, +} + +/// Origin and approved-bytes evidence recorded when the package was +/// accepted, so every later launch can prove the on-disk bytes again. +#[derive(Debug, Clone)] +pub struct TrustEvidence { + pub origin: signature::InstallOrigin, + /// Content digest recorded for a community approval; bundled and signed + /// packages verify through the pinned policy instead. + pub expected_content_digest: Option, } /// Cached scan result. The frontend rescans at startup and after installs, diff --git a/windows/tauri/src-tauri/src/plugin_host/run_plans.rs b/windows/tauri/src-tauri/src/plugin_host/run_plans.rs index e7f0b2eda9..c3dedb1ad0 100644 --- a/windows/tauri/src-tauri/src/plugin_host/run_plans.rs +++ b/windows/tauri/src-tauri/src/plugin_host/run_plans.rs @@ -34,8 +34,7 @@ pub fn declarative_plugins_run_plan( relative_file_path: String, user_arguments: Vec, ) -> Result { - let package = super::launch::package_by_id(&app, &plugin_id) - .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; + let package = super::launch::verified_package_by_id(&app, &plugin_id)?; expand_plan( &package.manifest, &plugin_id, @@ -58,8 +57,7 @@ pub fn declarative_plugins_test_plan( language_id: String, workspace_path: String, ) -> Result { - let package = super::launch::package_by_id(&app, &plugin_id) - .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; + let package = super::launch::verified_package_by_id(&app, &plugin_id)?; let relative_project_file_paths = existing_project_markers(&package.manifest, &language_id, Path::new(&workspace_path)); expand_plan( diff --git a/windows/tauri/src-tauri/src/plugin_host/scan.rs b/windows/tauri/src-tauri/src/plugin_host/scan.rs index 59bb55e0b6..35186bd466 100644 --- a/windows/tauri/src-tauri/src/plugin_host/scan.rs +++ b/windows/tauri/src-tauri/src/plugin_host/scan.rs @@ -54,13 +54,47 @@ fn scan_package(package: DiscoveredPackage) -> ScannedPackage { } match validate_manifest(&manifest) { Ok(plugin) => { + let plugin_id = plugin["id"].as_str().unwrap_or(&fallback_id).to_string(); + let version = plugin["version"].as_str().unwrap_or("0").to_string(); + // The trust gate runs after manifest validation so Core's stable + // manifest codes stay primary, and before the package is + // registered: signature policy, origin, and the approved content + // digest must all hold for the bytes on disk right now. + let Some(origin) = package.origin else { + return quarantined( + package, + plugin_id, + &version, + catalog_rejection( + super::signature::TrustError::ProvenanceMissing.code(), + super::signature::TrustError::ProvenanceMissing.message(), + None, + ), + ); + }; + if let Err(error) = super::signature::verify_for_origin( + &package.root, + &plugin_id, + &version, + &manifest, + origin, + package.content_digest.as_deref(), + super::signature::trusted_keys(), + ) { + return quarantined( + package, + plugin_id, + &version, + catalog_rejection(error.code(), error.message(), None), + ); + } let entry = CatalogEntry { - plugin_id: plugin["id"].as_str().unwrap_or(&fallback_id).to_string(), + plugin_id: plugin_id.clone(), display_name: plugin["displayName"] .as_str() .unwrap_or("plugin") .to_string(), - version: plugin["version"].as_str().unwrap_or("0").to_string(), + version: version.clone(), api_version: plugin["pluginAPIVersion"].as_u64().unwrap_or(0) as u32, source: package.source, enabled: package.enabled, @@ -85,6 +119,10 @@ fn scan_package(package: DiscoveredPackage) -> ScannedPackage { entry, root: package.root, manifest, + trust: Some(super::TrustEvidence { + origin, + expected_content_digest: package.content_digest, + }), } } Err(rejection) => quarantined( @@ -119,6 +157,7 @@ fn quarantined( }, root: package.root, manifest: Value::Null, + trust: None, } } diff --git a/windows/tauri/src-tauri/src/plugin_host/signature.rs b/windows/tauri/src-tauri/src/plugin_host/signature.rs new file mode 100644 index 0000000000..576c4f82b9 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/signature.rs @@ -0,0 +1,798 @@ +//! Publisher signature and content-digest verification for plugin packages. +//! +//! Byte-compatible with the macOS `PluginPackageSignature` (CryptoKit): the +//! canonical payload lines, UTF-8 base64url tokens, file-path sorting, and +//! the content digest follow the same specification, so one signed package +//! verifies identically on both platforms. Package bytes are hashed as UTF-8 +//! relative paths with `/` separators; names that are not valid UTF-8 (which +//! Windows can produce) cannot receive a token and are refused. +//! +//! Trust policy lives here, not in the package: the host pins the signature +//! requirement of every official plugin ID, so a package cannot downgrade it +//! by declaring `vendor.signatureRequirement` itself, and `sameTeamAsHost` is +//! only honored for packages that ship inside the host's own installation. + +use base64::Engine; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::OnceLock; + +/// Signature document file name inside a package root; excluded from every +/// digest computation, and its presence forces verification. +pub const SIGNATURE_FILE_NAME: &str = "lithe-plugin-signature.json"; + +/// Signature schema version; must stay equal to the macOS implementation. +pub const SCHEMA_VERSION: u32 = 1; +pub const ALGORITHM: &str = "ed25519"; +/// Key ID used for newly signed official packages; same as macOS. +pub const OFFICIAL_KEY_ID: &str = "lithe-official-plugins-v2"; +/// Standard-base64 Ed25519 verification key for [`OFFICIAL_KEY_ID`]. This is +/// a public key, not a secret; the private signing key never enters this +/// repository. +pub const OFFICIAL_PUBLIC_KEY_BASE64: &str = "yTjUaLpCNXXEQDuan5Ymw5rCtR0JeTNmh6hyTfOFjTs="; + +const STANDARD_BASE64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; +const BASE64URL_NO_PAD: base64::engine::GeneralPurpose = + base64::engine::general_purpose::URL_SAFE_NO_PAD; + +/// The signature document stored in `lithe-plugin-signature.json`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignatureDocument { + pub schema_version: u32, + pub algorithm: String, + pub key_id: String, + pub plugin_id: String, + pub plugin_version: String, + /// Relative package path (UTF-8, `/` separators) to lowercase hex SHA-256. + pub files: BTreeMap, + /// Standard-base64 Ed25519 signature over the canonical payload. + pub signature: String, +} + +/// Every rejection this module produces, with the stable code the catalog +/// reports for a quarantined package. +#[derive(Debug, Clone, PartialEq)] +pub enum TrustError { + /// The package (or its origin) claims no publisher signature, but the + /// host only trusts unsigned community packages that are not pinned + /// official plugins. + UnsignedNotAllowed, + /// The trust policy requires a publisher signature and none is present. + SignatureMissing, + /// A signature document exists but is malformed, does not match the + /// package, or does not verify. + SignatureInvalid(String), + /// The document names a key ID this host does not trust. + UntrustedKey(String), + /// The package files differ from the digest recorded when the user + /// approved it (changed, added, or removed files). + ContentChanged, + /// An installed package record carries no content digest, so its bytes + /// cannot be proven to be what the user approved (schema 1 migration). + ProvenanceMissing, + /// A `sameTeamAsHost` package outside the host's own installation. + HostSealRequired, + /// The manifest's `vendor.signatureRequirement` is not a known value. + InvalidPolicy, +} + +impl TrustError { + /// Stable error code for catalog quarantines and diagnostics. + pub fn code(&self) -> &'static str { + match self { + TrustError::UnsignedNotAllowed => "plugin.trust.unsignedNotAllowed", + TrustError::SignatureMissing => "plugin.trust.signatureMissing", + TrustError::SignatureInvalid(_) => "plugin.trust.signatureInvalid", + TrustError::UntrustedKey(_) => "plugin.trust.untrustedKey", + TrustError::ContentChanged => "plugin.trust.contentChanged", + TrustError::ProvenanceMissing => "plugin.trust.provenanceMissing", + TrustError::HostSealRequired => "plugin.trust.hostSealRequired", + TrustError::InvalidPolicy => "plugin.trust.invalidPolicy", + } + } + + pub fn message(&self) -> String { + match self { + TrustError::UnsignedNotAllowed => { + "the package is unsigned and this host only loads unsigned community packages \ + after user confirmation" + .to_string() + } + TrustError::SignatureMissing => { + "the package requires a publisher signature but none is present".to_string() + } + TrustError::SignatureInvalid(detail) => { + format!("the package signature is invalid: {detail}") + } + TrustError::UntrustedKey(key_id) => format!( + "the package is signed by key {key_id}, which this version of Lithe does not trust" + ), + TrustError::ContentChanged => { + "the package differs from the contents the user approved".to_string() + } + TrustError::ProvenanceMissing => { + "the install record has no content digest; reinstall the plugin to approve it \ + with the current trust rules" + .to_string() + } + TrustError::HostSealRequired => { + "the package can only be trusted when it ships inside Lithe".to_string() + } + TrustError::InvalidPolicy => { + "the package declares an unknown signature requirement".to_string() + } + } + } +} + +/// How a package proves its origin; mirrors the macOS trust model. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InstallOrigin { + /// Ships inside the host installation; sealed by distribution, not by a + /// package signature. + Bundled, + /// Downloaded from a marketplace source as a publisher-signed package. + Marketplace, + /// An unsigned package the user installed after confirming its + /// permissions and programs; integrity comes from the recorded digest. + Community, +} + +/// Signature requirement of a package: the pinned host policy for official +/// plugin IDs, otherwise the manifest's own `vendor.signatureRequirement`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignatureRequirement { + /// Trusted only inside the host's own installation. + SameTeamAsHost, + /// Must carry a valid publisher signature over every file. + PublisherPackage, + /// No Lithe signature; community trust only. + Unsigned, +} + +/// Trust policy the host pins per official plugin ID. The pinned policy wins +/// over whatever the package declares, so an official ID cannot downgrade the +/// signature it must carry. This mirrors the macOS `OfficialPluginCatalog` +/// for the IDs that exist on Windows; the bundled PHP contract package ships +/// inside the installer, so it is pinned `sameTeamAsHost`. +pub fn official_signature_requirement(plugin_id: &str) -> Option { + match plugin_id { + "dev.lithe.plugin.php-support" => Some(SignatureRequirement::SameTeamAsHost), + "dev.lithe.plugin.java-support" => Some(SignatureRequirement::SameTeamAsHost), + "dev.lithe.plugin.go-support" => Some(SignatureRequirement::PublisherPackage), + _ => None, + } +} + +/// Parses the manifest's `vendor.signatureRequirement`. +pub fn declared_signature_requirement( + manifest: &serde_json::Value, +) -> Result { + match manifest["vendor"]["signatureRequirement"].as_str() { + Some("sameTeamAsHost") => Ok(SignatureRequirement::SameTeamAsHost), + Some("publisherPackage") => Ok(SignatureRequirement::PublisherPackage), + Some("none") => Ok(SignatureRequirement::Unsigned), + _ => Err(TrustError::InvalidPolicy), + } +} + +/// The host's trust keys, keyed by key ID. A malformed embedded key is a +/// build defect and must never be silently skipped. +pub fn trusted_keys() -> &'static BTreeMap { + static KEYS: OnceLock> = OnceLock::new(); + KEYS.get_or_init(|| { + let encoded = OFFICIAL_PUBLIC_KEY_BASE64; + let bytes = STANDARD_BASE64 + .decode(encoded) + .expect("embedded official plugin signing key must be valid base64"); + let key = VerifyingKey::from_bytes( + &bytes + .try_into() + .expect("embedded official plugin signing key must be 32 bytes"), + ) + .expect("embedded official plugin signing key must be a valid Ed25519 key"); + BTreeMap::from([(OFFICIAL_KEY_ID.to_string(), key)]) + }) +} + +/// True when the package root carries a signature document. +pub fn has_signature_file(package_root: &Path) -> bool { + package_root.join(SIGNATURE_FILE_NAME).is_file() +} + +/// Reads and parses the signature document of a package root. +pub fn read_signature_document(package_root: &Path) -> Result { + let path = package_root.join(SIGNATURE_FILE_NAME); + let text = std::fs::read_to_string(&path).map_err(|_| TrustError::SignatureMissing)?; + serde_json::from_str(&text) + .map_err(|error| TrustError::SignatureInvalid(format!("unreadable document: {error}"))) +} + +/// UTF-8 base64url token without padding, exactly as the macOS signer emits. +fn token(value: &str) -> String { + BASE64URL_NO_PAD.encode(value.as_bytes()) +} + +/// Key IDs are written raw into the signed payload to keep existing +/// signatures byte-compatible, so they are restricted to a safe alphabet. +pub fn is_valid_key_id(key_id: &str) -> bool { + !key_id.is_empty() + && key_id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | '_')) +} + +/// The exact bytes signed by the publisher, identical to the macOS +/// implementation: header lines, then one line per file in ascending path +/// order (UTF-8 byte order, which matches the macOS scalar order for the NFC +/// names packages must use), every line terminated by `\n`. +pub fn canonical_payload( + key_id: &str, + plugin_id: &str, + plugin_version: &str, + files: &BTreeMap, +) -> Vec { + let mut payload = format!( + "schemaVersion={SCHEMA_VERSION}\nalgorithm={ALGORITHM}\nkeyID={key_id}\npluginID={}\npluginVersion={}\n", + token(plugin_id), + token(plugin_version) + ); + for (path, digest) in files { + payload.push_str(&format!("file={}={}\n", token(path), digest)); + } + payload.into_bytes() +} + +/// SHA-256 over `token(path)=digest\n` for every file in ascending path +/// order. Recorded when an unsigned package is installed and rechecked on +/// every load, so a changed, added, or removed file is detected. +pub fn content_digest(package_root: &Path) -> Result { + let digests = file_digests(package_root)?; + let lines: String = digests + .iter() + .map(|(path, digest)| format!("{}={digest}\n", token(path))) + .collect(); + Ok(hex(&Sha256::digest(lines.as_bytes()))) +} + +/// Verifies the signature document of a package when one is present. A +/// present document is always verified — even for packages trusted through +/// another route — so a forged or damaged document is never ignored. +pub fn verify_signature_if_present( + package_root: &Path, + plugin_id: &str, + plugin_version: &str, + trusted_keys: &BTreeMap, +) -> Result<(), TrustError> { + if !has_signature_file(package_root) { + return Ok(()); + } + let document = read_signature_document(package_root)?; + verify_document( + package_root, + plugin_id, + plugin_version, + &document, + trusted_keys, + ) +} + +/// Install-time policy check for a package about to be recorded as `origin`. +/// Same rules as [`verify_for_origin`], without the content-digest +/// requirement for community packages: the digest is being created by this +/// install and cannot be checked against a record that does not exist yet. +pub fn verify_for_install( + package_root: &Path, + plugin_id: &str, + plugin_version: &str, + manifest: &serde_json::Value, + origin: InstallOrigin, + trusted_keys: &BTreeMap, +) -> Result<(), TrustError> { + let declared = declared_signature_requirement(manifest)?; + let pinned = official_signature_requirement(plugin_id).unwrap_or(declared); + // Unsigned packages are community packages and nothing else: any other + // origin, or a host-pinned official ID, needs a real signature. + if declared == SignatureRequirement::Unsigned || origin == InstallOrigin::Community { + let allowed = declared == SignatureRequirement::Unsigned + && origin == InstallOrigin::Community + && official_signature_requirement(plugin_id).is_none(); + if !allowed { + return Err(TrustError::UnsignedNotAllowed); + } + return verify_signature_if_present(package_root, plugin_id, plugin_version, trusted_keys); + } + if pinned == SignatureRequirement::PublisherPackage && !has_signature_file(package_root) { + return Err(TrustError::SignatureMissing); + } + verify_signature_if_present(package_root, plugin_id, plugin_version, trusted_keys)?; + if pinned == SignatureRequirement::PublisherPackage { + return Ok(()); + } + if origin == InstallOrigin::Bundled { + return Ok(()); + } + Err(TrustError::HostSealRequired) +} + +/// Full load-time trust check: the host policy decides what the package must +/// prove, the package cannot downgrade it. +/// +/// `expected_content_digest` is the digest recorded when the user approved an +/// unsigned community package; load paths must pass it for community origin, +/// install paths use [`verify_for_install`] while they are creating the +/// record. +pub fn verify_for_origin( + package_root: &Path, + plugin_id: &str, + plugin_version: &str, + manifest: &serde_json::Value, + origin: InstallOrigin, + expected_content_digest: Option<&str>, + trusted_keys: &BTreeMap, +) -> Result<(), TrustError> { + verify_for_install( + package_root, + plugin_id, + plugin_version, + manifest, + origin, + trusted_keys, + )?; + if origin == InstallOrigin::Community { + let expected = expected_content_digest.ok_or(TrustError::ProvenanceMissing)?; + let actual = content_digest(package_root)?; + if actual != expected { + return Err(TrustError::ContentChanged); + } + } + Ok(()) +} + +/// Verifies one signature document against the live package bytes. +pub fn verify_document( + package_root: &Path, + plugin_id: &str, + plugin_version: &str, + document: &SignatureDocument, + trusted_keys: &BTreeMap, +) -> Result<(), TrustError> { + let Some(public_key) = trusted_keys.get(&document.key_id) else { + return Err(TrustError::UntrustedKey(document.key_id.clone())); + }; + let signature_bytes = STANDARD_BASE64 + .decode(document.signature.as_bytes()) + .map_err(|_| TrustError::SignatureInvalid("signature is not base64".to_string()))?; + if document.schema_version != SCHEMA_VERSION + || document.algorithm != ALGORITHM + || !is_valid_key_id(&document.key_id) + || document.plugin_id != plugin_id + || document.plugin_version != plugin_version + || signature_bytes.len() != 64 + { + return Err(TrustError::SignatureInvalid( + "document fields do not match the package".to_string(), + )); + } + let actual = file_digests(package_root)?; + if actual.keys().len() != document.files.len() + || actual + .keys() + .zip(document.files.keys()) + .any(|(left, right)| left != right) + { + return Err(TrustError::SignatureInvalid( + "the package file list does not match its signature".to_string(), + )); + } + for (path, digest) in &actual { + if document.files.get(path).map(String::as_str) != Some(digest.as_str()) { + return Err(TrustError::SignatureInvalid(format!( + "the package file was changed after signing: {path}" + ))); + } + } + let payload = canonical_payload( + &document.key_id, + &document.plugin_id, + &document.plugin_version, + &document.files, + ); + let signature = Signature::from_slice(&signature_bytes) + .map_err(|_| TrustError::SignatureInvalid("signature is not 64 bytes".to_string()))?; + public_key + .verify(&payload, &signature) + .map_err(|_| TrustError::SignatureInvalid("signature does not verify".to_string())) +} + +/// SHA-256 digest of every package file, keyed by relative path with `/` +/// separators in ascending order. Refuses reparse points, symlinks, unsafe +/// names, and non-UTF-8 names; the signature document itself is skipped. +pub fn file_digests(package_root: &Path) -> Result, TrustError> { + if !package_root.is_dir() { + return Err(TrustError::SignatureInvalid( + "the package root is not a directory".to_string(), + )); + } + let mut digests = BTreeMap::new(); + walk_files(package_root, "", &mut digests)?; + Ok(digests) +} + +fn walk_files( + directory: &Path, + prefix: &str, + digests: &mut BTreeMap, +) -> Result<(), TrustError> { + let entries = std::fs::read_dir(directory).map_err(|error| { + TrustError::SignatureInvalid(format!("unreadable package entry: {error}")) + })?; + for entry in entries { + let entry = entry + .map_err(|error| TrustError::SignatureInvalid(format!("unreadable entry: {error}")))?; + let path = entry.path(); + let name = entry.file_name(); + // Windows package names are compared as UTF-8 text; names with + // invalid UTF-16 surrogates cannot receive a signature token. + let name = name.to_str().ok_or_else(|| { + TrustError::SignatureInvalid("a package entry name is not UTF-8".to_string()) + })?; + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + TrustError::SignatureInvalid(format!("{} is unreadable: {error}", path.display())) + })?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + // Reparse-point attribute: covers symbolic links and junctions. + if metadata.file_attributes() & 0x0400 != 0 { + return Err(TrustError::SignatureInvalid(format!( + "{} is a reparse point; package entries must be regular files", + path.display() + ))); + } + } + #[cfg(not(windows))] + if metadata.file_type().is_symlink() { + return Err(TrustError::SignatureInvalid(format!( + "{} is a symbolic link; package entries must be regular files", + path.display() + ))); + } + if metadata.is_dir() { + let prefix = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + }; + walk_files(&path, &prefix, digests)?; + continue; + } + if !metadata.is_file() { + return Err(TrustError::SignatureInvalid(format!( + "{} is not a regular file", + path.display() + ))); + } + let relative = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + }; + if relative.contains('\0') || relative.contains('\n') || relative.contains('\r') { + return Err(TrustError::SignatureInvalid(format!( + "the package contains an unsupported file: {relative}" + ))); + } + if relative.split('/').any(|part| part == "..") { + return Err(TrustError::SignatureInvalid(format!( + "the package contains an unsupported file: {relative}" + ))); + } + // Only the root-level signature document is excluded; a nested file + // with the same name is an ordinary package file. + if relative == SIGNATURE_FILE_NAME { + continue; + } + let bytes = std::fs::read(&path).map_err(|error| { + TrustError::SignatureInvalid(format!("{} is unreadable: {error}", path.display())) + })?; + digests.insert(relative, hex(&Sha256::digest(&bytes))); + } + Ok(()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::STANDARD; + use ed25519_dalek::{Signer, SigningKey}; + + /// The RFC 8032 Ed25519 test key, used as the shared public test seed in + /// `signature-vectors.json`; it must never sign real packages. + const RFC8032_SEED_HEX: &str = + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"; + const RFC8032_PUBLIC_HEX: &str = + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + /// RFC 8032 TEST 1: signature over the empty message. + const RFC8032_EMPTY_SIGNATURE_HEX: &str = "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"; + + fn test_signing_key() -> SigningKey { + let mut seed = [0u8; 32]; + seed.copy_from_slice(&hex_to_bytes(RFC8032_SEED_HEX)); + SigningKey::from_bytes(&seed) + } + + fn hex_to_bytes(hex: &str) -> Vec { + (0..hex.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&hex[index..index + 2], 16).expect("hex byte")) + .collect() + } + + /// The primitive is anchored to the public RFC 8032 vector, not to this + /// crate's own round-trip, so both platforms verify the same known answer. + #[test] + fn ed25519_matches_the_rfc_8032_test_vector() { + let key = test_signing_key(); + assert_eq!(hex(&key.verifying_key().to_bytes()), RFC8032_PUBLIC_HEX); + let signature = key.sign(b""); + assert_eq!(hex(&signature.to_bytes()), RFC8032_EMPTY_SIGNATURE_HEX); + assert!(key.verifying_key().verify(b"", &signature).is_ok()); + } + + /// Prints the shared fixture values; run with `--ignored --nocapture` + /// when the fixture must be regenerated, then paste into + /// shared/contracts/plugins/v1/fixtures/signature-vectors.json. The + /// committed fixture was produced from the same construction (see the + /// fixture's description); the end-to-end test below re-derives it. + #[test] + #[ignore = "fixture generator; output is committed as signature-vectors.json"] + fn generate_signature_vectors_fixture() { + let key = test_signing_key(); + let files_content = [ + ("plugin.json", "plugin manifest bytes"), + ("language-server/bin/server.js", "console.log('server');\n"), + ("插件/服务器.js", "console.log('非 ASCII 路径');\n"), + ("日本語/README.md", "# サンプル\n"), + ("assets/заметки.txt", "заметки\n"), + ]; + let files: BTreeMap = files_content + .iter() + .map(|(path, content)| (path.to_string(), hex(&Sha256::digest(content.as_bytes())))) + .collect(); + let payload = canonical_payload("lithe-test-v1", "dev.example.plugin", "0.3.0", &files); + let signature = key.sign(&payload); + let digest = { + let lines: String = files + .iter() + .map(|(path, digest)| format!("{}={digest}\n", token(path))) + .collect(); + hex(&Sha256::digest(lines.as_bytes())) + }; + println!( + "{}", + serde_json::json!({ + "ed25519": { + "seedHex": RFC8032_SEED_HEX, + "publicKeyHex": RFC8032_PUBLIC_HEX, + "messageEmptySignatureHex": RFC8032_EMPTY_SIGNATURE_HEX, + }, + "packageFiles": files_content + .iter() + .map(|(path, content)| (path.to_string(), content.to_string())) + .collect::>(), + "canonicalPayload": { + "keyID": "lithe-test-v1", + "pluginID": "dev.example.plugin", + "pluginVersion": "0.3.0", + "files": files, + "payloadBase64": STANDARD.encode(&payload), + "signatureBase64": STANDARD.encode(signature.to_bytes()), + }, + "contentDigest": { + "files": files, + "digest": digest, + }, + }) + ); + } + + #[test] + fn token_encoding_matches_the_macos_base64url_rules() { + assert_eq!(token("plugin.json"), "cGx1Z2luLmpzb24"); + // `+` and `/` map to `-` and `_`; padding is omitted. + assert_eq!(token("~?"), "fj8"); + // Non-ASCII paths are base64url of their UTF-8 bytes: the round trip + // reproduces the input and the output uses the URL-safe alphabet. + let path = "插件/服务器.js"; + let token = token(path); + assert!(!token.contains('+') && !token.contains('/') && !token.contains('=')); + let decoded = BASE64URL_NO_PAD + .decode(token.as_bytes()) + .expect("token decodes"); + assert_eq!(String::from_utf8(decoded).expect("UTF-8"), path); + } + + #[test] + fn unsafe_package_entries_are_refused() { + let root = std::env::temp_dir().join(format!("lithe-sig-unsafe-{}", std::process::id())); + let nested = root.join("sub"); + std::fs::create_dir_all(&nested).expect("dir"); + std::fs::write(nested.join("server.js"), b"ok").expect("file"); + let digests = file_digests(&root).expect("digests"); + assert!(digests.contains_key("sub/server.js")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn official_ids_keep_their_pinned_signature_requirement() { + assert_eq!( + official_signature_requirement("dev.lithe.plugin.php-support"), + Some(SignatureRequirement::SameTeamAsHost) + ); + assert_eq!( + official_signature_requirement("dev.lithe.plugin.go-support"), + Some(SignatureRequirement::PublisherPackage) + ); + assert_eq!(official_signature_requirement("io.example.plugin"), None); + let manifest = serde_json::json!({ + "vendor": { "signatureRequirement": "none" } + }); + assert_eq!( + declared_signature_requirement(&manifest), + Ok(SignatureRequirement::Unsigned) + ); + assert_eq!( + declared_signature_requirement(&serde_json::json!({ "vendor": {} })), + Err(TrustError::InvalidPolicy) + ); + } + + /// Regenerating the fixture changes the expected digest; the test then + /// forces a deliberate look at the fixture instead of drifting silently. + #[test] + fn shared_fixture_verifies_end_to_end() { + let fixture_path = std::path::PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/signature-vectors.json" + )); + let fixture: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&fixture_path).expect("signature-vectors.json is readable"), + ) + .expect("signature-vectors.json is JSON"); + + // The primitive anchor in the fixture matches the RFC vector above. + assert_eq!(fixture["ed25519"]["seedHex"], RFC8032_SEED_HEX); + assert_eq!(fixture["ed25519"]["publicKeyHex"], RFC8032_PUBLIC_HEX); + assert_eq!( + fixture["ed25519"]["messageEmptySignatureHex"], + RFC8032_EMPTY_SIGNATURE_HEX + ); + + // The recorded package signature verifies with the shared public key + // and the recorded canonical payload reproduces byte for byte. + let payload_case = &fixture["canonicalPayload"]; + let mut files = BTreeMap::new(); + for (path, digest) in payload_case["files"].as_object().expect("files object") { + files.insert( + path.clone(), + digest.as_str().expect("digest string").to_string(), + ); + } + let payload = canonical_payload( + payload_case["keyID"].as_str().expect("keyID"), + payload_case["pluginID"].as_str().expect("pluginID"), + payload_case["pluginVersion"] + .as_str() + .expect("pluginVersion"), + &files, + ); + assert_eq!( + STANDARD.encode(&payload), + payload_case["payloadBase64"] + .as_str() + .expect("payloadBase64"), + "canonical payload bytes diverged from the shared fixture" + ); + let key = test_signing_key(); + let signature = key.sign(&payload); + assert_eq!( + STANDARD.encode(signature.to_bytes()), + payload_case["signatureBase64"] + .as_str() + .expect("signatureBase64"), + "deterministic Ed25519 must reproduce the shared signature" + ); + + // The materialized package tree verifies through the real verify path. + let root = std::env::temp_dir().join(format!( + "lithe-sig-vector-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + for (path, content) in fixture["packageFiles"].as_object().expect("packageFiles") { + let target = root.join(path); + std::fs::create_dir_all(target.parent().expect("parent")).expect("package dir"); + std::fs::write(&target, content.as_str().expect("content")).expect("package file"); + } + let document = SignatureDocument { + schema_version: SCHEMA_VERSION, + algorithm: ALGORITHM.to_string(), + key_id: payload_case["keyID"].as_str().expect("keyID").to_string(), + plugin_id: payload_case["pluginID"] + .as_str() + .expect("pluginID") + .to_string(), + plugin_version: payload_case["pluginVersion"] + .as_str() + .expect("pluginVersion") + .to_string(), + files, + signature: payload_case["signatureBase64"] + .as_str() + .expect("signatureBase64") + .to_string(), + }; + verify_document( + &root, + &document.plugin_id, + &document.plugin_version, + &document, + &{ + let mut keys = BTreeMap::new(); + keys.insert("lithe-test-v1".to_string(), key.verifying_key()); + keys + }, + ) + .expect("fixture package verifies"); + + // Tampering with any file is rejected. + std::fs::write(root.join("plugin.json"), b"changed bytes").expect("tamper"); + assert!(matches!( + verify_document( + &root, + &document.plugin_id, + &document.plugin_version, + &document, + &{ + let mut keys = BTreeMap::new(); + keys.insert("lithe-test-v1".to_string(), key.verifying_key()); + keys + } + ), + Err(TrustError::SignatureInvalid(_)) + )); + let _ = std::fs::remove_dir_all(&root); + + // The recorded content digest matches the un-tampered tree. + let digest_case = &fixture["contentDigest"]; + let mut digest_files = BTreeMap::new(); + for (path, digest) in digest_case["files"].as_object().expect("digest files") { + digest_files.insert( + path.clone(), + digest.as_str().expect("digest string").to_string(), + ); + } + let expected_lines: String = digest_files + .iter() + .map(|(path, digest)| format!("{}={digest}\n", token(path))) + .collect(); + assert_eq!( + hex(&Sha256::digest(expected_lines.as_bytes())), + digest_case["digest"].as_str().expect("digest"), + "content digest diverged from the shared fixture" + ); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/store.rs b/windows/tauri/src-tauri/src/plugin_host/store.rs index 290ab9075e..1226525f6a 100644 --- a/windows/tauri/src-tauri/src/plugin_host/store.rs +++ b/windows/tauri/src-tauri/src/plugin_host/store.rs @@ -55,7 +55,12 @@ pub fn workspace_state_key(workspace_path: &str) -> String { format!("{hash:016x}") } -/// Persistent install record of one installed plugin. +/// Persistent install record of one installed plugin (schema 2). The record +/// is the only trust evidence for installed packages: which origin the +/// package came from, which byte digest the user approved, and which +/// programs were approved. Schema 1 records carry no provenance; the scan +/// quarantines them instead of reconstructing trust from `enabled` or the +/// manifest. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InstallationRecord { @@ -63,20 +68,94 @@ pub struct InstallationRecord { pub plugin_id: String, /// Active version directory name under the plugin directory. pub version: String, + /// Version the user can roll back to; verified with its own provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_version: Option, #[serde(default = "default_enabled")] pub enabled: bool, + /// Where the active version came from. `None` marks a schema 1 record + /// written before provenance existed; such records never load. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, + /// Origin of `previousVersion`; rollback verifies the restored package + /// by how it was installed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_origin: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub marketplace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_marketplace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disk: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_disk: Option, +} + +impl InstallationRecord { + /// Provenance of the active version: exactly one source owns an approval, + /// so marketplace and disk provenance are mutually exclusive. + pub fn active_content_digest(&self) -> Option<&str> { + match (&self.marketplace, &self.disk) { + (Some(_), Some(_)) | (None, None) => None, + (Some(marketplace), None) => Some(&marketplace.content_digest), + (None, Some(disk)) => Some(&disk.content_digest), + } + } + + /// Content digest recorded for `previousVersion`, for rollback checks. + pub fn previous_content_digest(&self) -> Option<&str> { + match (&self.previous_marketplace, &self.previous_disk) { + (Some(_), Some(_)) | (None, None) => None, + (Some(marketplace), None) => Some(&marketplace.content_digest), + (None, Some(disk)) => Some(&disk.content_digest), + } + } } fn default_enabled() -> bool { true } +/// A user-selected local package, without any claim of repository +/// verification. The content digest binds the approval to the installed +/// files. Mirrors the macOS `PluginDiskProvenance`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiskProvenance { + pub file_name: String, + pub archive_sha256: Option, + pub content_digest: String, + /// Canonical JSON of every approved program from the install review. + pub approved_programs: Vec, +} + +/// Where a marketplace installation came from and what the user approved. +/// Reserved for the marketplace milestone (M2), which writes this record; +/// the scan already rechecks its digest for community origin. Mirrors the +/// macOS `PluginMarketplaceProvenance`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketplaceProvenance { + pub source_id: String, + pub source_name: String, + pub repository: String, + pub archive_sha256: String, + pub content_digest: String, + pub approved_programs: Vec, +} + /// One package directory candidate found on disk. pub struct DiscoveredPackage { /// Directory that directly contains `plugin.json`. pub root: PathBuf, pub source: &'static str, pub enabled: bool, + /// Trust origin recorded for this package. Bundled packages carry + /// `Bundled`; `None` marks a schema 1 record without provenance, which + /// the scan quarantines. + pub origin: Option, + /// Provenance of the active version (digest of the approved bytes). + pub content_digest: Option, /// Set when the package was found but its install record is missing or /// invalid; scan quarantines the package with this reason. pub record_problem: Option, @@ -106,6 +185,8 @@ pub fn discover_installed_in(install_root: &Path) -> Result Result 2 { packages.push(quarantined(format!( "unsupported installation record schema {}", record.schema_version ))); continue; } + if record.schema_version == 1 { + // A schema 1 record predates provenance: the enabled flag and the + // manifest say nothing about which bytes were approved, so the + // package is quarantined until the user reinstalls it. + packages.push(quarantined( + "the install record predates content provenance; reinstall the plugin to \ + approve it with the current trust rules" + .to_string(), + )); + continue; + } + if record.plugin_id + != plugin_directory + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default() + { + packages.push(quarantined( + "the install record does not match its plugin directory".to_string(), + )); + continue; + } let version_directory = plugin_directory.join(&record.version); if !version_directory.join("plugin.json").is_file() { packages.push(quarantined(format!( @@ -140,10 +243,20 @@ pub fn discover_installed_in(install_root: &Path) -> Result Result continue; } if package_directory.join("plugin.json").is_file() { - packages.push(DiscoveredPackage { - root: package_directory, - source: "bundled", - enabled: true, - record_problem: None, - }); + packages.push(bundled_package(package_directory)); continue; } let mut versions: Vec<(String, PathBuf)> = Vec::new(); @@ -200,16 +308,25 @@ pub fn discover_bundled_in(bundled_root: &Path) -> Result else { continue; }; - packages.push(DiscoveredPackage { - root: best, - source: "bundled", - enabled: true, - record_problem: None, - }); + packages.push(bundled_package(best)); } Ok(packages) } +/// A bundled package discovered inside the host installation. Bundled +/// packages carry no install record: they ship with the app and are trusted +/// through their location, subject to the pinned signature policy. +fn bundled_package(root: PathBuf) -> DiscoveredPackage { + DiscoveredPackage { + root, + source: "bundled", + enabled: true, + origin: Some(super::signature::InstallOrigin::Bundled), + content_digest: None, + record_problem: None, + } +} + /// Dot-separated version comparison with numeric-aware chunks, so `1.10` /// sorts after `1.9` without pulling in a semver dependency. pub fn compare_versions(left: &str, right: &str) -> Ordering { diff --git a/windows/tauri/src-tauri/src/plugin_host/tests.rs b/windows/tauri/src-tauri/src/plugin_host/tests.rs index 2d5aee99c4..77caec831b 100644 --- a/windows/tauri/src-tauri/src/plugin_host/tests.rs +++ b/windows/tauri/src-tauri/src/plugin_host/tests.rs @@ -163,14 +163,27 @@ fn scan_accepts_valid_packages_and_quarantines_broken_ones() { let root = unique_temp_root("scan"); let package_root = root.join("acme.php"); std::fs::create_dir_all(&package_root).expect("package dir"); - let manifest = read_fixture("language-packaged-server.plugin.json"); + // A community install requires a non-official ID and an unsigned trust + // declaration; the fixture's official-namespace manifest is reshaped the + // way a real disk package looks. + let mut manifest = read_fixture("language-packaged-server.plugin.json"); + manifest["id"] = serde_json::json!("io.example.acme-php"); + manifest["vendor"]["signatureRequirement"] = serde_json::json!("none"); std::fs::write( package_root.join("plugin.json"), serde_json::to_string_pretty(&manifest).expect("manifest text"), ) .expect("manifest file"); - let accepted = scan_package_at(&package_root, None); + let accepted = scan_package_at( + &package_root, + None, + Some(crate::plugin_host::signature::InstallOrigin::Community), + Some( + crate::plugin_host::signature::content_digest(&package_root) + .expect("digest of the package"), + ), + ); assert_eq!(accepted.entry.status, "accepted"); assert_eq!(accepted.entry.plugin_id, manifest["id"]); assert_eq!(accepted.entry.languages.len(), 1); @@ -180,7 +193,7 @@ fn scan_accepts_valid_packages_and_quarantines_broken_ones() { ); std::fs::write(package_root.join("plugin.json"), "{ not json").expect("broken manifest"); - let broken = scan_package_at(&package_root, None); + let broken = scan_package_at(&package_root, None, None, None); assert_eq!(broken.entry.status, "rejected"); assert_eq!( broken.entry.rejection.as_ref().expect("rejection").code, @@ -213,19 +226,46 @@ fn store_problems_quarantine_packages_with_valid_manifests() { // A record naming a missing version is a store problem too. std::fs::write( plugin_directory.join("installation.json"), - json!({"schemaVersion": 1, "pluginId": "acme.php", "version": "9.9.9"}).to_string(), + json!({"schemaVersion": 2, "pluginId": "acme.php", "version": "9.9.9", "origin": "community"}).to_string(), ) .expect("record file"); let discovered = store::discover_installed_in(&root).expect("discovery works"); assert!(discovered[0].record_problem.is_some()); - // Correct record: discovered at the recorded version, enabled by default. + // A schema 1 record predates content provenance: it is quarantined even + // though it names the installed version, because the enabled flag and + // the manifest cannot prove which bytes the user approved. std::fs::write( plugin_directory.join("installation.json"), json!({"schemaVersion": 1, "pluginId": "acme.php", "version": "1.0.0"}).to_string(), ) .expect("record file"); let discovered = store::discover_installed_in(&root).expect("discovery works"); + assert!(discovered[0].record_problem.is_some()); + assert!(discovered[0] + .record_problem + .as_deref() + .unwrap_or_default() + .contains("provenance")); + + // A schema 2 community record without a content digest is the same trap. + std::fs::write( + plugin_directory.join("installation.json"), + json!({"schemaVersion": 2, "pluginId": "acme.php", "version": "1.0.0", "origin": "community"}).to_string(), + ) + .expect("record file"); + let discovered = store::discover_installed_in(&root).expect("discovery works"); + assert!(discovered[0].record_problem.is_some()); + + // Correct record: discovered at the recorded version, enabled by default. + write_record_v2( + &plugin_directory, + "acme.php", + "1.0.0", + &version_directory, + true, + ); + let discovered = store::discover_installed_in(&root).expect("discovery works"); assert!(discovered[0].record_problem.is_none()); assert_eq!(discovered[0].root, version_directory); assert!(discovered[0].enabled); @@ -261,24 +301,30 @@ fn catalog_build_combines_bundled_and_installed_sources() { let bundled_root = temporary.join("OfficialPlugins"); let installed_version = install_root.join("acme.php").join("1.0.0"); std::fs::create_dir_all(&installed_version).expect("installed dir"); + let mut installed_manifest = read_fixture("language-packaged-server.plugin.json"); + installed_manifest["id"] = serde_json::json!("io.example.acme-php"); + installed_manifest["vendor"]["signatureRequirement"] = serde_json::json!("none"); std::fs::write( installed_version.join("plugin.json"), - serde_json::to_string(&read_fixture("language-packaged-server.plugin.json")) - .expect("manifest"), + serde_json::to_string_pretty(&installed_manifest).expect("manifest"), ) .expect("manifest file"); - std::fs::write( - install_root.join("acme.php").join("installation.json"), - json!({"schemaVersion": 1, "pluginId": "acme.php", "version": "1.0.0", "enabled": false}) - .to_string(), - ) - .expect("record file"); + write_record_v2( + &install_root.join("acme.php"), + "acme.php", + "1.0.0", + &installed_version, + false, + ); + // A bundled package is trusted through its location: its manifest may + // keep the sameTeamAsHost policy the bundled PHP contract package uses. + let mut bundled_manifest = read_fixture("language-system-command.plugin.json"); + bundled_manifest["vendor"]["signatureRequirement"] = serde_json::json!("sameTeamAsHost"); let bundled_directory = bundled_root.join("acme.go").join("2.0.0"); std::fs::create_dir_all(&bundled_directory).expect("bundled dir"); std::fs::write( bundled_directory.join("plugin.json"), - serde_json::to_string(&read_fixture("language-system-command.plugin.json")) - .expect("manifest"), + serde_json::to_string_pretty(&bundled_manifest).expect("manifest"), ) .expect("manifest file"); @@ -316,24 +362,261 @@ fn unique_temp_root(label: &str) -> PathBuf { root } -/// Scans one package directory through the production scan path. +/// Scans one package directory through the production scan path. `origin` +/// and `content_digest` carry the trust evidence an installation record +/// would provide; `None` mirrors a record without provenance. fn scan_package_at( root: &std::path::Path, record_problem: Option, + origin: Option, + content_digest: Option, ) -> super::ScannedPackage { super::scan::scan_package_for_tests(DiscoveredPackage { root: root.to_path_buf(), source: "installed", enabled: true, + origin, + content_digest, record_problem, }) } +/// Writes a schema 2 community record naming `version`, with the content +/// digest of `version_directory` as the approved-bytes evidence. +fn write_record_v2( + plugin_directory: &std::path::Path, + plugin_id: &str, + version: &str, + version_directory: &std::path::Path, + enabled: bool, +) { + let record = json!({ + "schemaVersion": 2, + "pluginId": plugin_id, + "version": version, + "enabled": enabled, + "origin": "community", + "disk": { + "fileName": "plugin.zip", + "archiveSHA256": null, + "contentDigest": crate::plugin_host::signature::content_digest(version_directory) + .expect("digest of the package directory"), + "approvedPrograms": [], + } + }); + std::fs::write( + plugin_directory.join("installation.json"), + record.to_string(), + ) + .expect("record file"); +} + /// Fixture reader shared with the install test module. pub(crate) fn read_fixture_for_tests(name: &str) -> serde_json::Value { read_fixture(name) } +/// Writes a community-installable package (non-official ID, unsigned trust +/// declaration) and its schema 2 record into an install root. +fn install_community_package(install_root: &std::path::Path, version: &str) -> std::path::PathBuf { + let plugin_directory = install_root.join("io.example.acme-php"); + let version_directory = plugin_directory.join(version); + std::fs::create_dir_all(&version_directory).expect("version dir"); + let mut manifest = read_fixture("language-packaged-server.plugin.json"); + manifest["id"] = serde_json::json!("io.example.acme-php"); + manifest["vendor"]["signatureRequirement"] = serde_json::json!("none"); + manifest["version"] = serde_json::json!(version); + std::fs::write( + version_directory.join("plugin.json"), + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest file"); + std::fs::write(version_directory.join("server.js"), b"entry").expect("package file"); + write_record_v2( + &plugin_directory, + "io.example.acme-php", + version, + &version_directory, + true, + ); + version_directory +} + +/// The real bundled PHP contract package passes the M1 trust gate exactly +/// the way the app's resource-directory scan will treat it: bundled origin, +/// pinned `sameTeamAsHost` policy, no install record. +#[test] +fn real_bundled_contract_package_passes_the_trust_gate() { + let source = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../Plugins/win/Official/PhpSupportContract" + ); + let root = unique_temp_root("trust-bundled"); + let bundled_root = root + .join("OfficialPlugins") + .join("dev.lithe.plugin.php-support"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + copy_directory_snapshot_for_tests(std::path::Path::new(source), &bundled_root); + + let catalog = build_catalog_from(&root.join("OfficialPlugins"), &root.join("none")) + .expect("catalog builds"); + let package = catalog + .iter() + .find(|package| package.entry.source == "bundled") + .expect("bundled entry"); + let entry = &package.entry; + assert_eq!(entry.status, "accepted", "{:?}", entry.rejection); + assert_eq!(entry.plugin_id, "dev.lithe.plugin.php-support"); + assert!(entry.enabled); + // The trust evidence recorded for launch re-verification is bundled. + assert!(matches!( + package.trust.as_ref().map(|trust| trust.origin), + Some(crate::plugin_host::signature::InstallOrigin::Bundled) + )); + let _ = std::fs::remove_dir_all(&root); +} + +/// Copies a package tree without following reparse points, mirroring the +/// snapshot copy the installer performs. +fn copy_directory_snapshot_for_tests(source: &std::path::Path, target: &std::path::Path) { + std::fs::create_dir_all(target).expect("target dir"); + for entry in std::fs::read_dir(source).expect("source readable") { + let entry = entry.expect("entry"); + let entry_target = target.join(entry.file_name()); + if entry.path().is_dir() { + copy_directory_snapshot_for_tests(&entry.path(), &entry_target); + } else { + std::fs::copy(entry.path(), &entry_target).expect("file copy"); + } + } +} + +/// The bytes of an installed community package are rechecked against the +/// recorded content digest on every scan, so a package tampered while the +/// app was not running is quarantined at the next startup instead of loaded. +#[test] +fn scan_quarantines_a_tampered_installed_package() { + let root = unique_temp_root("trust-tamper"); + let install_root = root.join("plugins"); + install_community_package(&install_root, "1.0.0"); + + let catalog = build_catalog_from(&root.join("none"), &install_root).expect("catalog builds"); + let entry = &catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry") + .entry; + assert_eq!(entry.status, "accepted"); + + // Tamper while the app is not running: the next scan must refuse. + std::fs::write( + install_root + .join("io.example.acme-php") + .join("1.0.0") + .join("server.js"), + b"tampered", + ) + .expect("tamper"); + let catalog = build_catalog_from(&root.join("none"), &install_root).expect("catalog builds"); + let entry = &catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry") + .entry; + assert_eq!(entry.status, "rejected"); + let rejection = entry.rejection.as_ref().expect("rejection"); + assert_eq!(rejection.code, "plugin.trust.contentChanged"); + + // An added file is the same rejection: the approved file set is exact. + std::fs::write( + install_root + .join("io.example.acme-php") + .join("1.0.0") + .join("server.js"), + b"entry", + ) + .expect("restore"); + std::fs::write( + install_root + .join("io.example.acme-php") + .join("1.0.0") + .join("extra.js"), + b"added", + ) + .expect("add file"); + let catalog = build_catalog_from(&root.join("none"), &install_root).expect("catalog builds"); + let entry = &catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry") + .entry; + assert_eq!( + entry.rejection.as_ref().expect("rejection").code, + "plugin.trust.contentChanged" + ); + let _ = std::fs::remove_dir_all(&root); +} + +/// The host's official-ID trust policy overrides the package declaration: a +/// pinned official ID cannot install as an unsigned community package even +/// when its manifest claims a signature requirement the host could honor. +#[test] +fn official_ids_reject_unsigned_community_installs() { + let root = unique_temp_root("trust-official"); + let package_root = root.join("pkg"); + std::fs::create_dir_all(&package_root).expect("package dir"); + let mut manifest = read_fixture("language-packaged-server.plugin.json"); + // The pinned PHP support ID; its bundled contract package legitimately + // uses sameTeamAsHost, which a disk install can never prove. + manifest["id"] = serde_json::json!("dev.lithe.plugin.php-support"); + manifest["vendor"]["signatureRequirement"] = serde_json::json!("sameTeamAsHost"); + std::fs::write( + package_root.join("plugin.json"), + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest file"); + std::fs::write(package_root.join("server.js"), b"entry").expect("package file"); + + let scanned = scan_package_at( + &package_root, + None, + Some(crate::plugin_host::signature::InstallOrigin::Community), + None, + ); + assert_eq!(scanned.entry.status, "rejected"); + let rejection = scanned.entry.rejection.as_ref().expect("rejection"); + assert_eq!( + rejection.code, "plugin.trust.unsignedNotAllowed", + "{rejection:?}" + ); + let _ = std::fs::remove_dir_all(&root); +} + +/// A signature document present in a community package must verify: a forged +/// or damaged document is never silently ignored. +#[test] +fn a_present_signature_document_must_verify_even_for_community_packages() { + let root = unique_temp_root("trust-signature"); + let install_root = root.join("plugins"); + let version_directory = install_community_package(&install_root, "1.0.0"); + std::fs::write( + version_directory.join("lithe-plugin-signature.json"), + r#"{"schemaVersion":1,"algorithm":"ed25519","keyID":"lithe-test-v1","pluginID":"io.example.acme-php","pluginVersion":"1.0.0","files":{},"signature":"AAAA"}"#, + ) + .expect("signature file"); + let catalog = build_catalog_from(&root.join("none"), &install_root).expect("catalog builds"); + let entry = &catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry") + .entry; + assert_eq!( + entry.rejection.as_ref().expect("rejection").code, + "plugin.trust.signatureInvalid" + ); + let _ = std::fs::remove_dir_all(&root); +} + /// M3 setting-store tests exercise the two persistence layers directly: the /// global per-plugin file and the workspace's `.lithe/plugin-settings.json`. mod settings_tests { @@ -529,6 +812,8 @@ fn concurrent_catalog_refreshes_publish_in_discovery_order() { Ok(vec![scan_package_at( &PathBuf::from("catalog-new.plugin"), None, + None, + None, )]) }) .unwrap(); From bb0e29abb86f9ff19668d69397ef367d1fd720d3 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sun, 25 Oct 2026 11:57:33 +0800 Subject: [PATCH 2/7] fix(windows): bind plugin approval and recover install transactions --- ...6-10-22-windows-declarative-plugin-host.md | 4 +- .../features/declarative-plugin-contract.json | 2 +- .../src-tauri/src/plugin_host/install.rs | 794 +++++++-- .../src/plugin_host/install_tests.rs | 1454 ++++++++++++++++- .../tauri/src-tauri/src/plugin_host/launch.rs | 6 +- .../tauri/src-tauri/src/plugin_host/scan.rs | 20 +- .../src-tauri/src/plugin_host/signature.rs | 241 ++- .../tauri/src-tauri/src/plugin_host/store.rs | 35 +- .../tauri/src-tauri/src/plugin_host/tests.rs | 103 +- 9 files changed, 2430 insertions(+), 229 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md index 203ccf98be..d679685877 100644 --- a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md +++ b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md @@ -32,7 +32,7 @@ Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模 - bundled 包从 `resource_dir()/OfficialPlugins` 只读加载,多版本目录取最高版本;运行时不写安装目录与资源目录,遵守发行基线只读边界。 - 每个插件的语言服务器状态目录在 `app_local_data_dir()/plugin-state//`,由宿主注入启动计划请求。 - 信任模型 v1(Windows 插件对齐路线图 M1,见 `docs/development/windows-plugin-parity-plan.md`):发布者 Ed25519 验签已接入 `plugin_host/signature.rs`,与 macOS `PluginPackageSignature` 字节兼容(canonical payload、UTF-8 base64url token、按原始路径排序、每文件 SHA-256 与 contentDigest),采用成熟的 `ed25519-dalek`,固定官方公钥 `lithe-official-plugins-v2`;生产私钥不进入仓库。官方 ID 的信任策略由宿主固定(`php/java-support=sameTeamAsHost`、`go-support=publisherPackage`),覆盖包自声明;`sameTeamAsHost` 只信任宿主安装资源内的 bundled 包,磁盘/市场包不能凭目录名获得该身份。签名文档存在即必须验证,坏签名不会被忽略。磁盘导入与市场下载(M2+)仍走 `plugins.marketplace.installReview` 全量审批(权限 + 完整程序清单),审批界面明示"未认证发布者"。 -- 安装记录 schema 2 是安装事务的一部分:`origin`、marketplace/disk provenance(全包内容摘要与批准程序的 canonical JSON)、上一版本的 provenance 都随版本发布一起写入;安装确认、staging 发布前、启动扫描、运行/测试计划入口与回滚都会重新校验完整文件集合和摘要。schema 1 旧记录没有 provenance,扫描直接隔离(`plugin.trust.provenanceMissing`)并要求重新安装,绝不凭 `enabled` 或清单摘要补造发布者身份和已批准字节。 +- 安装记录 schema 2 是安装事务的一部分:`origin`、marketplace/disk provenance(全包内容摘要与批准程序的 canonical JSON)、上一版本的 provenance 都随版本发布一起写入;安装确认、staging 发布前、启动扫描、运行/测试计划入口与回滚都会重新校验完整文件集合和摘要(community 与 marketplace origin 都复检已批准摘要,签名只证明发布者、不豁免字节比对)。版本激活与记录提交是同一事务:记录先写入 temp、激活成功后才提交,提交失败恢复旧字节并保留旧记录,恢复失败如实报错。schema 1 旧记录没有 provenance,扫描直接隔离(`plugin.trust.provenanceMissing`)并要求重新安装,绝不凭 `enabled` 或清单摘要补造发布者身份和已批准字节。bundled 身份只能由可信资源发现器产生:installed 记录声明 `origin`/`previousOrigin=bundled` 一律隔离,`sameTeamAsHost` 在扫描、启动与回滚路径都校验真实资源根包含,记录声明与回滚候选同样拒绝。审批绑定 prepare 时的整包字节:prepare 冻结整包摘要、清单摘要与审查程序,confirm 与 staging 对同一审核身份校验,prepare 之后替换/新增/删除文件都被拒绝。磁盘渠道接受已验签的 `publisherPackage` 包(origin 保持真实 disk/community),声明 `sameTeamAsHost` 的磁盘包即使签名有效也拒绝。 - 磁盘导入的私有快照、staging 重验、内容摘要复查与回滚语义照抄 macOS 侧规则(见声明式语言插件 Note 的磁盘安装节),Windows 不另造审查语义。 ### 启动接线(M1 落地形状) @@ -112,7 +112,7 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 - 网页隔离与上下文回归:`web.rs` 覆盖同包和外站 URL 的导航/桥接共用检查、真实路径与内部/外部目录链接;Windows 用无需提权的 junction,Unix 用目录符号链接。`plugin-web-appearance.test.ts` 执行原生宿主桥脚本,验证语言、主题、根变量、主题事件与外站不暴露桥;前端文件测试验证原生真实目标传递与拒绝传播。 - 版本探测回归:`runtime_probe_collects_output_and_rejects_overflow`、`runtime_probe_reclaims_inherited_pipes_after_parent_exit` 使用当前测试二进制作为固定夹具;后者让父进程退出、后代保留管道,断言调用有界结束且后代已退出。测试环境变量仅附加到子进程,不改测试运行器的全局环境。运行 WindowsRust 与 Frontend 计时 harness,检查 `.artifacts/test-stability/index.html`;真实 WebView2 验证还要由本机 HTTP 计数器确认外站 GET 为零,同时检查导航、弹窗/下载、动态主题、包文件和发行目录哈希不变;单凭页面 URL 没变不能证明请求未外发。 -- 路线图 M1 信任链:`plugin_host/signature.rs` 用 RFC 8032 测试密钥锚定 Ed25519 原语;共享公开向量 `shared/contracts/plugins/v1/fixtures/signature-vectors.json`(含非 ASCII 文件名、canonical payload 字节、确定性签名与 contentDigest)由 `shared_fixture_verifies_end_to_end` 断言,Swift 端 `PluginSignatureVectorTests` 消费同一文件(macOS 实机运行待 CI)。`install_tests` 覆盖 staged 字节绑定批准摘要、同版本替换拒绝改动字节、staging 失败保留现行版本、回滚候选按其自身 provenance 验证;`tests.rs` 覆盖重启扫描隔离篡改/增删文件、官方 ID 拒绝无签名社区安装、社区包中存在的签名文档必须验证。`declarative_plugins_confirm_disk_install` 与回滚保持 #1424 的逐窗口停止屏障,失败时旧版本与旧记录保持可用。 +- 路线图 M1 信任链:`plugin_host/signature.rs` 用 RFC 8032 测试密钥锚定 Ed25519 原语;共享公开向量 `shared/contracts/plugins/v1/fixtures/signature-vectors.json`(含非 ASCII 文件名、canonical payload 字节、确定性签名与 contentDigest)由 `shared_fixture_verifies_end_to_end` 断言,Swift 端 `PluginSignatureVectorTests` 消费同一文件(macOS 实机运行待 CI)。`install_tests` 走真实 prepare→review→confirm→publish→scan 路径(测试密钥经 cfg(test) 注入,生产不可达):记录提交失败恢复旧字节与旧记录并可重试、首次安装记录失败不留字节、staged 字节绑定批准摘要、同版本替换拒绝改动字节、已签名磁盘包安装与加载、未知 key/坏签名/sameTeam 磁盘包拒绝、prepare 后替换/增删文件拒绝、回滚候选按其自身 provenance 验证且 bundled 声明拒绝;`tests.rs` 覆盖重启扫描隔离篡改/增删文件、installed 记录伪造 bundled 来源隔离、官方 ID 拒绝无签名社区安装(seal 错误)、社区包中存在的签名文档必须验证;`signature.rs` 单测覆盖真实资源根包含检查。`declarative_plugins_confirm_disk_install` 与回滚保持 #1424 的逐窗口停止屏障,失败时旧版本与旧记录保持可用。确认编排里,会话身份只解析一次且位于统一清理块内,其值既作为真实 `lifecycle::begin` 的屏障键、也显式传入共享编排作为发布前身份校验的期望值——helper 不重读身份,发布前拒绝身份漂移(屏障期间会话换成其他插件时,任何字节都不在错误屏障下发布);安装记录的读取与回滚链构造同样在屏障内完成。屏障拒绝与校验拒绝(含身份解析失败)都释放快照与会话记录。事务中断遗留的 `*.previous-tmp` 恢复备份(完整版本名命名,相邻补丁版本不碰撞)在下一次发布前按 durable record 当前批准的摘要判定:备份即批准字节则恢复、批准字节已在他处完整存在则清理备份、无法证明则拒绝歧义状态并完整保留数据,跨版本的孤儿备份在其批准字节可证时先归位自身版本目录,恢复失败保留备份并如实报错,绝不盲删。恢复前还校验记录的插件身份与单段版本路径,损坏记录不能把备份移到其他插件或用户目录。清理失败的回归从真实发布事务留下备份后再触发下一次记录提交失败,避免只手工创建预期路径而漏掉实际备份命名错误;测试文件锁与临时目录均用作用域清理。 - M4 Web 工具窗口:`plugin_host/web.rs` 测试覆盖包路径规范化(`..`、编码分隔符、空段拒绝)、CSP 内容断言(default-src none、无 wasm-unsafe-eval、frame-ancestors none)、桥脚本冻结与作用域、状态键隔离;web-panel fixture 随 `ui:[1,2]` 声明提升转为被宿主接受(能力门控测试同步)。桥 v0 的 build.*/functions.* 返回 `plugin.web.unknownMethod`,等构建系统能力接入。 diff --git a/shared/platform-feature-matrix/features/declarative-plugin-contract.json b/shared/platform-feature-matrix/features/declarative-plugin-contract.json index 5ccd10e685..b5e6707d5b 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-contract.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-contract.json @@ -59,6 +59,6 @@ "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:[1,2], serverProcess:1, settings:[1,2], ui:[1,2]},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。 后续安全修复已补插件身份传递、运行中和待启动会话排空、跨窗口包变更确认、完整启动前检查及 ui 1 文本/命令入口;目前 WebView 仍为独立窗口,停靠位置、图标、动作状态及布局持久化未完成。 M1 信任链已接入:windows/tauri/src-tauri/src/plugin_host/signature.rs 与 macOS PluginPackageSignature 字节兼容(canonical payload、UTF-8 base64url token、按原始路径排序、每文件 SHA-256、contentDigest),使用成熟的 ed25519-dalek 校验固定官方公钥(lithe-official-plugins-v2);官方 ID 信任策略由宿主固定(php/java-support=sameTeamAsHost、go-support=publisherPackage),覆盖包自声明,sameTeamAsHost 仅信任 bundled 资源;签名文档存在即必须验证。安装记录升为 schema 2:origin、marketplace/disk provenance(全包内容摘要与批准程序)、当前/上一版本证据成一致事务;schema 1 旧记录不凭 enabled 伪造信任,扫描直接隔离并要求重新安装。安装确认、staging 发布前、启动扫描、运行/测试计划入口与回滚均校验完整文件集合与摘要;ZIP 大小写冲突条目、reparse point、路径逃逸与签名文档损坏均被拒绝。双端共享公开向量 shared/contracts/plugins/v1/fixtures/signature-vectors.json(RFC 8032 测试密钥锚点,非 ASCII 文件名覆盖),Rust 端经 shared_fixture_verifies_end_to_end 断言;Swift 端 PluginSignatureVectorTests 消费同一文件,macOS 实机运行待 CI。生产私钥不进入源码与测试。", + "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:[1,2], serverProcess:1, settings:[1,2], ui:[1,2]},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。 后续安全修复已补插件身份传递、运行中和待启动会话排空、跨窗口包变更确认、完整启动前检查及 ui 1 文本/命令入口;目前 WebView 仍为独立窗口,停靠位置、图标、动作状态及布局持久化未完成。 M1 信任链已接入:windows/tauri/src-tauri/src/plugin_host/signature.rs 与 macOS PluginPackageSignature 字节兼容(canonical payload、UTF-8 base64url token、按原始路径排序、每文件 SHA-256、contentDigest),使用成熟的 ed25519-dalek 校验固定官方公钥(lithe-official-plugins-v2);官方 ID 信任策略由宿主固定(php/java-support=sameTeamAsHost、go-support=publisherPackage),覆盖包自声明,sameTeamAsHost 仅信任 bundled 资源;签名文档存在即必须验证。安装记录升为 schema 2:origin、marketplace/disk provenance(全包内容摘要与批准程序)、当前/上一版本证据成一致事务,版本激活与记录提交同事务、提交失败恢复旧字节与旧记录;schema 1 旧记录不凭 enabled 伪造信任,扫描直接隔离并要求重新安装;bundled 身份仅由可信资源发现器产生,installed 记录声明 bundled(含 previousOrigin)一律隔离,sameTeamAsHost 在扫描、启动与回滚均校验真实资源根包含。prepare 冻结整包摘要与审查程序,confirm 与 staging 对同一审核身份校验,prepare 后替换/新增/删除文件拒绝;磁盘渠道接受已验签 publisherPackage 包(保留真实 disk provenance),sameTeam 磁盘包拒绝。安装确认、staging 发布前、启动扫描、运行/测试计划入口与回滚均校验完整文件集合与摘要(community 与 marketplace origin 都复检已批准摘要);ZIP 大小写冲突条目、reparse point、路径逃逸与签名文档损坏均被拒绝。双端共享公开向量 shared/contracts/plugins/v1/fixtures/signature-vectors.json(RFC 8032 测试密钥锚点,非 ASCII 文件名覆盖),Rust 端经 shared_fixture_verifies_end_to_end 断言;Swift 端 PluginSignatureVectorTests 消费同一文件,macOS 实机运行待 CI。生产私钥不进入源码与测试。 安装中断恢复使用完整版本名备份并复核 durable record 批准摘要,校验插件身份和单段版本路径;真实替换产生的备份清理失败、下一次记录提交失败与越界记录均有 Windows 回归。", "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移未完成,宿主 UI 仅完成文本和命令入口,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 仍缺停靠、图标和动作状态等对齐,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。 Windows 安全回归:plugin_host::launch 的版本探测必须在父进程退出、后代继承管道时仍有界结束并清理进程树;web 回归验证真实文档来源与工作区文件目标。 Windows 语言资源整合:声明式插件的运行分组、管理页与安装审查使用共享生成词条;中英文回归验证安装名称/版本、程序数量和启停标签,并确认用户值中的占位符保持字面文本。 Windows 宿主修复回归:在 Core startServer 尚未返回时禁用插件,晚到会话必须先停止再拒绝;即时设置携带 pluginId,Core 接受并下推对应会话。缺失 requiredPackagePaths/runtimeRequirements 或 validationArguments 非零退出时拒绝启动。Hello Lithe 文本窗口、菜单、命令面板和工具栏可见,禁用后全部撤销;停靠、图标、动作状态及持久布局仍待接入,保持 partial。" } diff --git a/windows/tauri/src-tauri/src/plugin_host/install.rs b/windows/tauri/src-tauri/src/plugin_host/install.rs index e948c2c75b..d5de1b9cf9 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install.rs @@ -80,13 +80,21 @@ pub struct InstallReview { pub new_programs: Vec, } -/// Source facts recorded beside the snapshot, so the installation record can -/// name what the user installed without changing the confirm-call contract. +/// The evidence frozen when the user's review was prepared, recorded beside +/// the snapshot: what the user installed, and exactly which bytes and which +/// review identity prepare showed. Confirm and staging must reach the same +/// answers, so nothing can change between review and install. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct SnapshotSource { file_name: String, archive_sha256: Option, + /// Digest of the reviewed `plugin.json`. + manifest_digest: String, + /// Whole-package content digest of the reviewed snapshot. + content_digest: String, + /// Canonical JSON programs of the prepare-time review. + approved_programs: Vec, } /// Prepares a disk import from a ZIP archive or an unpacked directory. The @@ -98,15 +106,23 @@ pub fn declarative_plugins_prepare_disk_install( app: AppHandle, source_path: String, ) -> Result { - let temporary_root = app - .path() - .app_local_data_dir() - .map_err(|error| error.to_string())? - .join("plugin-temp"); + let temporary_root = snapshot_root(&app)?; std::fs::create_dir_all(&temporary_root).map_err(|error| error.to_string())?; let session_id = format!("{}{}", SNAPSHOT_PREFIX, uuid_like_suffix()); - let snapshot = temporary_root.join(&session_id); - let source = PathBuf::from(&source_path); + prepare_disk_install_snapshot(&temporary_root, &session_id, &source_path) +} + +/// The testable core of the prepare command: snapshot the source, review it, +/// and park the session under `temporary_root`. A failed prepare discards +/// its snapshot and session record. +pub(crate) fn prepare_disk_install_snapshot( + temporary_root: &Path, + session_id: &str, + source_path: &str, +) -> Result { + let snapshot = validated_session_path(temporary_root, session_id)?; + std::fs::create_dir_all(temporary_root).map_err(|error| error.to_string())?; + let source = PathBuf::from(source_path); let result = (|| { let file_name = source .file_name() @@ -129,21 +145,51 @@ pub fn declarative_plugins_prepare_disk_install( )); } let manifest = read_package_manifest(&snapshot)?; + let plugin_id = manifest["id"] + .as_str() + .ok_or("manifest has no id")? + .to_string(); + let version = manifest["version"] + .as_str() + .ok_or("manifest has no version")? + .to_string(); + // Trust is verified before the review is offered: a signature + // document must already verify here, and an unsigned package must be + // community-installable, so the review cannot be offered for bytes + // the host would refuse anyway. + super::signature::verify_for_install( + &snapshot, + &plugin_id, + &version, + &manifest, + super::signature::InstallOrigin::Community, + None, + &super::signature::trusted_keys(), + ) + .map_err(|error| error.message())?; + // The whole-package digest is frozen now: confirm and staging bind + // the install to exactly these bytes. + let frozen_digest = + super::signature::content_digest(&snapshot).map_err(|error| error.message())?; + let review = review_snapshot(&manifest, None)?; // Source facts live beside the snapshot, not inside it: every file // under the snapshot becomes an installed package file. std::fs::write( - snapshot_source_path(&temporary_root, &session_id), + snapshot_source_path(temporary_root, session_id), serde_json::to_string(&SnapshotSource { file_name, archive_sha256, + manifest_digest: manifest_replay_digest(&manifest), + content_digest: frozen_digest, + approved_programs: canonical_programs(&review.programs), }) .map_err(|error| error.to_string())?, ) .map_err(|error| error.to_string())?; Ok(PreparedInstall { - session_id: session_id.clone(), - review: review_snapshot(&manifest, None)?, - manifest_digest: content_digest(&manifest), + session_id: session_id.to_string(), + review, + manifest_digest: manifest_replay_digest(&manifest), display_name: manifest["displayName"] .as_str() .unwrap_or("plugin") @@ -151,8 +197,8 @@ pub fn declarative_plugins_prepare_disk_install( }) })(); if result.is_err() { - discard_snapshot(&temporary_root.join(&session_id)); - discard_source_metadata(&temporary_root, &session_id); + discard_snapshot(&snapshot); + discard_source_metadata(temporary_root, session_id); } result } @@ -171,121 +217,272 @@ pub async fn declarative_plugins_confirm_disk_install( manifest_digest: String, ) -> Result { let temporary_root = snapshot_root(&app)?; + let install_root = store::install_root(&app)?; let snapshot = snapshot_path(&app, &session_id)?; - let source_record_path = snapshot_source_path(&temporary_root, &session_id); let result = async { - let source: SnapshotSource = serde_json::from_str( - &std::fs::read_to_string(&source_record_path) - .map_err(|error| format!("the install session has no source record: {error}"))?, - ) - .map_err(|error| format!("the install session source record is unreadable: {error}"))?; - let manifest = read_package_manifest(&snapshot)?; - if content_digest(&manifest) != manifest_digest { - return Err("the package changed after review; the import was cancelled".to_string()); - } - // Re-review with no previously approved programs: a disk install always - // requires a fresh full confirmation, matching the macOS rule. - let review = review_snapshot(&manifest, None)?; - let plugin_id = manifest["id"] - .as_str() - .ok_or("manifest has no id")? - .to_string(); - let version = manifest["version"] - .as_str() - .ok_or("manifest has no version")? - .to_string(); - - // Validate the final manifest against this host's capabilities before - // anything is written into the install root. - validate_manifest(&manifest).map_err(|rejection| rejection.message)?; - // Disk installs are community origin. The pinned trust policy still - // applies (an official ID cannot install as an unsigned community - // package), and a signature document present in the package must - // verify even though the community digest is the recorded evidence. - super::signature::verify_for_install( - &snapshot, - &plugin_id, - &version, - &manifest, - super::signature::InstallOrigin::Community, - super::signature::trusted_keys(), - ) - .map_err(|error| error.message())?; - let approved_digest = - super::signature::content_digest(&snapshot).map_err(|error| error.message())?; - + // The session identity is resolved once, inside the cleanup-guarded + // block: a malformed session fails into the same release path as + // everything else. This one value keys the real lifecycle barrier + // and is passed to the orchestration as the expected identity, so + // the pre-publish check compares against the barrier actually held. + let session_plugin = session_plugin_id(&temporary_root, &session_id)?; // A version switch invalidates the page's package bytes and bridge // authorization, so destroy all owned webviews before staging starts. - let mutation = super::lifecycle::begin(&app, &plugin_id).await?; - let install_root = store::install_root(&app)?; - let existing = read_installation_record(&install_root, &plugin_id); - let replaces_active = existing - .as_ref() - .map(|record| record.version == version) - .unwrap_or(false); - // The same-version rule keeps the previous chain intact: replacing - // the active version in place does not overwrite what the user can - // still roll back to. - let (previous_version, previous_origin) = if replaces_active { - ( - existing - .as_ref() - .and_then(|record| record.previous_version.clone()), - existing.as_ref().and_then(|record| record.previous_origin), - ) - } else { - ( - existing.as_ref().map(|record| record.version.clone()), - existing.as_ref().and_then(|record| record.origin), - ) - }; - let (previous_marketplace, previous_disk) = if replaces_active { - ( - existing - .as_ref() - .and_then(|record| record.previous_marketplace.clone()), - existing - .as_ref() - .and_then(|record| record.previous_disk.clone()), - ) - } else { - ( - existing - .as_ref() - .and_then(|record| record.marketplace.clone()), - existing.as_ref().and_then(|record| record.disk.clone()), - ) - }; - let record = InstallationRecord { - schema_version: 2, - plugin_id: plugin_id.clone(), - version: version.clone(), - previous_version, - enabled: true, - origin: Some(super::signature::InstallOrigin::Community), - previous_origin, - marketplace: None, - previous_marketplace, - disk: Some(DiskProvenance { - file_name: source.file_name, - archive_sha256: source.archive_sha256, - content_digest: approved_digest, - approved_programs: canonical_programs(&review.programs), - }), - previous_disk, - }; - publish_installed_version(&install_root, &plugin_id, &version, &snapshot, record)?; + let mutation = super::lifecycle::begin(&app, &session_plugin).await?; + // Inside the barrier: the session is re-verified and the install + // record is read and built from the record state as it exists now, + // so another mutation that committed during the drain is not + // overwritten with a stale rollback chain. + let confirmed = confirm_and_publish_in_barrier( + &install_root, + &temporary_root, + &session_id, + &manifest_digest, + &snapshot, + &session_plugin, + &|| Ok(()), + )?; rescan_after_change(&app)?; mutation.complete(true); - Ok(catalog_entry_of(&app, &plugin_id) - .unwrap_or_else(|| accepted_entry_fallback(&plugin_id, &version))) + Ok(catalog_entry_of(&app, &confirmed.plugin_id) + .unwrap_or_else(|| accepted_entry_fallback(&confirmed.plugin_id, &confirmed.version))) } .await; - discard_snapshot(&snapshot); - discard_source_metadata(&temporary_root, &session_id); + // Every way out of the command - success, validation refusal, barrier + // failure - releases the snapshot and the session record. + discard_install_session(&temporary_root, &session_id, &snapshot); + result +} + +/// Cheap pre-barrier read of the session's plugin identity. +#[cfg(test)] +pub(crate) fn session_plugin_id_for_tests( + temporary_root: &Path, + session_id: &str, +) -> Result { + session_plugin_id(temporary_root, session_id) +} + +fn session_plugin_id(temporary_root: &Path, session_id: &str) -> Result { + let snapshot = validated_session_path(temporary_root, session_id)?; + let manifest = read_package_manifest(&snapshot)?; + manifest["id"] + .as_str() + .map(str::to_string) + .ok_or_else(|| "the install session has no plugin id".to_string()) +} + +/// Releases the snapshot directory and the session record of one review. +#[cfg(test)] +pub(crate) fn discard_install_session_for_tests( + temporary_root: &Path, + session_id: &str, + snapshot: &Path, +) { + discard_install_session(temporary_root, session_id, snapshot) +} + +fn discard_install_session(temporary_root: &Path, session_id: &str, snapshot: &Path) { + discard_snapshot(snapshot); + discard_source_metadata(temporary_root, session_id); +} + +/// Runs the barrier-guarded section of a disk-install confirm, the same +/// sequence the command drives. `expected_plugin_id` is the identity the +/// caller resolved when it took its real barrier — the command awaits +/// `lifecycle::begin` for exactly this ID and passes it here, so the check +/// below proves the published bytes belong to the plugin whose barrier is +/// actually held. The session is re-verified and the install record is read +/// and built from the record state as it exists now, the identity is +/// re-checked before anything is published, and only then are the staged +/// bytes published. `begin` is injected so tests drive the same sequence +/// without an AppHandle; the real command passes a no-op because its barrier +/// is already held. A failed section releases the snapshot and session +/// record: the session cannot be retried through this call. +pub(crate) fn confirm_and_publish_in_barrier( + install_root: &Path, + temporary_root: &Path, + session_id: &str, + manifest_digest: &str, + snapshot: &Path, + expected_plugin_id: &str, + begin: &dyn Fn() -> Result<(), String>, +) -> Result { + let result = (|| { + begin()?; + let confirmed = confirm_disk_install_snapshot( + install_root, + temporary_root, + session_id, + manifest_digest, + )?; + // The identity must still be the one the barrier was taken for: + // publishing under another plugin's barrier would bypass its stop + // guarantees, so this check happens before any bytes move. The + // expected ID comes from the caller's own barrier acquisition, never + // from a second read of the session. + if confirmed.plugin_id != expected_plugin_id { + return Err(format!( + "the install session changed during confirmation; it now installs {} but the barrier was taken for {expected_plugin_id}", + confirmed.plugin_id + )); + } + publish_installed_version( + install_root, + &confirmed.plugin_id, + &confirmed.version, + snapshot, + confirmed.record.clone(), + )?; + Ok(confirmed) + })(); + if result.is_err() { + discard_install_session(temporary_root, session_id, snapshot); + } result } +/// The outcome of a confirmed review: everything the publish transaction +/// needs, validated against the snapshot as it existed at confirm time. +#[derive(Debug)] +pub(crate) struct ConfirmedDiskInstall { + pub plugin_id: String, + pub version: String, + pub record: InstallationRecord, +} + +/// The testable core of the confirm command: re-review the snapshot, verify +/// its trust, and build the installation record. Publishing happens later, +/// behind the lifecycle barrier, from the same validated record. +pub(crate) fn confirm_disk_install_snapshot( + install_root: &Path, + temporary_root: &Path, + session_id: &str, + manifest_digest: &str, +) -> Result { + let snapshot = validated_session_path(temporary_root, session_id)?; + let source: SnapshotSource = serde_json::from_str( + &std::fs::read_to_string(snapshot_source_path(temporary_root, session_id)) + .map_err(|error| format!("the install session has no source record: {error}"))?, + ) + .map_err(|error| format!("the install session source record is unreadable: {error}"))?; + let manifest = read_package_manifest(&snapshot)?; + // The caller echoes the manifest digest it reviewed; the session record + // freezes it independently, so a tampered caller cannot bless new bytes. + if manifest_replay_digest(&manifest) != manifest_digest + || manifest_replay_digest(&manifest) != source.manifest_digest + { + return Err("the package changed after review; the import was cancelled".to_string()); + } + // The whole package must still be exactly the bytes prepare froze: any + // replaced, added, or removed file is a rejection before anything runs. + let package_digest = + super::signature::content_digest(&snapshot).map_err(|error| error.message())?; + if package_digest != source.content_digest { + return Err("the package changed after review; the import was cancelled".to_string()); + } + // Re-review with no previously approved programs: a disk install always + // requires a fresh full confirmation, matching the macOS rule. Its + // identity must still be the one the user saw at prepare. + let review = review_snapshot(&manifest, None)?; + if canonical_programs(&review.programs) != source.approved_programs { + return Err( + "the reviewed programs changed after the package was prepared; the import was \ + cancelled" + .to_string(), + ); + } + let plugin_id = manifest["id"] + .as_str() + .ok_or("manifest has no id")? + .to_string(); + let version = manifest["version"] + .as_str() + .ok_or("manifest has no version")? + .to_string(); + + // Validate the final manifest against this host's capabilities before + // anything is written into the install root. + validate_manifest(&manifest).map_err(|rejection| rejection.message)?; + // Disk installs are community origin. The pinned trust policy still + // applies (an official ID cannot install as an unsigned community + // package), and a signature document present in the package must + // verify even though the community digest is the recorded evidence. + super::signature::verify_for_install( + &snapshot, + &plugin_id, + &version, + &manifest, + super::signature::InstallOrigin::Community, + None, + &super::signature::trusted_keys(), + ) + .map_err(|error| error.message())?; + + let existing = read_installation_record(install_root, &plugin_id); + let replaces_active = existing + .as_ref() + .map(|record| record.version == version) + .unwrap_or(false); + // The same-version rule keeps the previous chain intact: replacing + // the active version in place does not overwrite what the user can + // still roll back to. + let (previous_version, previous_origin) = if replaces_active { + ( + existing + .as_ref() + .and_then(|record| record.previous_version.clone()), + existing.as_ref().and_then(|record| record.previous_origin), + ) + } else { + ( + existing.as_ref().map(|record| record.version.clone()), + existing.as_ref().and_then(|record| record.origin), + ) + }; + let (previous_marketplace, previous_disk) = if replaces_active { + ( + existing + .as_ref() + .and_then(|record| record.previous_marketplace.clone()), + existing + .as_ref() + .and_then(|record| record.previous_disk.clone()), + ) + } else { + ( + existing + .as_ref() + .and_then(|record| record.marketplace.clone()), + existing.as_ref().and_then(|record| record.disk.clone()), + ) + }; + let record = InstallationRecord { + schema_version: 2, + plugin_id: plugin_id.clone(), + version: version.clone(), + previous_version, + enabled: true, + origin: Some(super::signature::InstallOrigin::Community), + previous_origin, + marketplace: None, + previous_marketplace, + disk: Some(DiskProvenance { + file_name: source.file_name, + archive_sha256: source.archive_sha256, + // The approved bytes are the ones prepare froze, re-checked above. + content_digest: package_digest, + approved_programs: source.approved_programs, + }), + previous_disk, + }; + Ok(ConfirmedDiskInstall { + plugin_id, + version, + record, + }) +} + /// Abandons an installation review and removes its private snapshot. #[tauri::command] pub fn declarative_plugins_cancel_disk_install( @@ -302,14 +499,7 @@ pub fn declarative_plugins_cancel_disk_install( } fn snapshot_path(app: &AppHandle, session_id: &str) -> Result { - if !session_id.starts_with(SNAPSHOT_PREFIX) - || session_id.contains('/') - || session_id.contains('\\') - || session_id.contains("..") - { - return Err("invalid install session".to_string()); - } - Ok(snapshot_root(app)?.join(session_id)) + validated_session_path(&snapshot_root(&app)?, session_id) } fn snapshot_root(app: &AppHandle) -> Result { @@ -324,6 +514,18 @@ fn snapshot_source_path(temporary_root: &Path, session_id: &str) -> PathBuf { temporary_root.join(format!("{session_id}.source.json")) } +/// Resolves and validates the snapshot directory of one review session. +fn validated_session_path(temporary_root: &Path, session_id: &str) -> Result { + if !session_id.starts_with(SNAPSHOT_PREFIX) + || session_id.contains('/') + || session_id.contains('\\') + || session_id.contains("..") + { + return Err("invalid install session".to_string()); + } + Ok(temporary_root.join(session_id)) +} + fn discard_source_metadata(temporary_root: &Path, session_id: &str) { let _ = std::fs::remove_file(snapshot_source_path(temporary_root, session_id)); } @@ -409,23 +611,13 @@ pub async fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> )); }; let previous_directory = plugin_directory.join(&previous); - let manifest = read_package_manifest(&previous_directory)?; - if manifest["id"].as_str() != Some(plugin_id.as_str()) - || manifest["version"].as_str() != Some(previous.as_str()) - { - return Err("the previous package does not match its installation record".to_string()); - } - validate_manifest(&manifest).map_err(|rejection| rejection.message)?; - super::signature::verify_for_origin( + verify_rollback_candidate( &previous_directory, &plugin_id, &previous, - &manifest, previous_origin, record.previous_content_digest(), - super::signature::trusted_keys(), - ) - .map_err(|error| error.message())?; + )?; let mutation = super::lifecycle::begin(&app, &plugin_id).await?; let swapped = InstallationRecord { schema_version: 2, @@ -490,9 +682,49 @@ pub(crate) fn publish_installed_version( version: &str, source: &Path, record: InstallationRecord, +) -> Result<(), String> { + publish_installed_version_with_backup_cleanup( + install_root, + plugin_id, + version, + source, + record, + &|path| std::fs::remove_dir_all(path), + ) +} + +/// Uses the same publication transaction with a replaceable final cleanup +/// operation. Cleanup failure is deliberately non-fatal after the durable +/// record commits; injecting it lets tests retain the real backup without +/// relying on Windows file-attribute or antivirus timing behavior. +pub(crate) fn publish_installed_version_with_backup_cleanup( + install_root: &Path, + plugin_id: &str, + version: &str, + source: &Path, + record: InstallationRecord, + cleanup_backup: &dyn Fn(&Path) -> std::io::Result<()>, ) -> Result<(), String> { let plugin_directory = install_root.join(plugin_id); std::fs::create_dir_all(&plugin_directory).map_err(|error| error.to_string())?; + // Leftover recovery backups of any version are judged before anything is + // staged, directed by the durable record's approved digest. + let stale_backups: Vec = std::fs::read_dir(&plugin_directory) + .map_err(|error| error.to_string())? + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.is_dir() + && path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.ends_with(".previous-tmp")) + .unwrap_or(false) + }) + .collect(); + for backup_directory in stale_backups { + resolve_recovery_backup(install_root, plugin_id, &backup_directory)?; + } let version_directory = plugin_directory.join(version); let staging_directory = plugin_directory.join(format!(".staging-{version}")); if staging_directory.exists() { @@ -513,7 +745,8 @@ pub(crate) fn publish_installed_version( version, &staged_manifest, origin, - super::signature::trusted_keys(), + None, + &super::signature::trusted_keys(), ) .map_err(|error| error.message())?; if let Some(expected) = record.active_content_digest() { @@ -523,11 +756,14 @@ pub(crate) fn publish_installed_version( return Err(super::signature::TrustError::ContentChanged.message()); } } - activate_staged_version(&staging_directory, &version_directory)?; - // The record only ever points at bytes that are already in place; if - // this write fails, the backup restore below keeps the old version - // and the old record consistent. - write_installation_record(install_root, &record) + activate_staged_version_and_record( + &staging_directory, + &version_directory, + install_root, + version, + &record, + cleanup_backup, + ) })(); if result.is_err() { let _ = std::fs::remove_dir_all(&staging_directory); @@ -535,37 +771,265 @@ pub(crate) fn publish_installed_version( result } -/// Replaces `version_directory` with the staged copy, keeping a backup so a -/// failed rename never leaves the install record pointing at missing bytes. -fn activate_staged_version( +/// Replaces `version_directory` with the staged copy and commits the +/// installation record as one transaction. The record is staged to its temp +/// path before any version byte moves, so a record that cannot be written +/// fails while the old version is still intact; if the record commit fails +/// after the swap, the previous bytes are restored and both failures are +/// reported instead of leaving new bytes under the old record. +/// Judges a recovery backup left by an interrupted transaction, directed by +/// the durable installation record's approved digest: restore the backup +/// only when it provably holds the approved bytes, remove it only when the +/// approved bytes are provably intact elsewhere, and refuse an ambiguous +/// state with every byte kept. Restore failures are reported, never +/// swallowed. +fn resolve_recovery_backup( + install_root: &Path, + plugin_id: &str, + backup_directory: &Path, +) -> Result<(), String> { + let refuse = |message: String| -> Result<(), String> { Err(message) }; + let backup_version = backup_directory + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_suffix(".previous-tmp")) + .ok_or("a recovery backup without a version name")? + .to_string(); + let durable = match read_installation_record(install_root, plugin_id) { + Some(record) if record.schema_version == 2 => record, + _ => { + return refuse( + "a recovery backup exists and the installation record cannot prove which bytes \ + are approved; the backup and any interrupted bytes were kept" + .to_string(), + ); + } + }; + // A user-writable record cannot choose another plugin or a path outside + // its version directory while recovery moves package bytes. + let is_version_component = |value: &str| { + !value.is_empty() + && !matches!(value, "." | "..") + && !value.contains(['/', '\\', ':']) + && !value.ends_with(['.', ' ']) + }; + if durable.plugin_id != plugin_id + || !is_version_component(&durable.version) + || !is_version_component(&backup_version) + { + return refuse( + "the recovery record does not name a valid version of this plugin; all bytes were kept" + .to_string(), + ); + } + let backup_version_directory = install_root.join(plugin_id).join(&backup_version); + let Some(approved_digest) = durable.active_content_digest() else { + return refuse( + "a recovery backup exists and the installation record carries no approved digest; \ + the backup and any interrupted bytes were kept" + .to_string(), + ); + }; + let backup_digest = super::signature::content_digest(backup_directory) + .map_err(|error| format!("the recovery backup is unreadable: {}", error.message()))?; + if durable.version == backup_version { + let version_digest = if backup_version_directory.exists() { + Some( + super::signature::content_digest(&backup_version_directory).map_err(|error| { + format!("the interrupted version is unreadable: {}", error.message()) + })?, + ) + } else { + None + }; + if version_digest.as_deref() == Some(approved_digest) { + // The version directory already holds the approved bytes; the + // backup is a stale leftover and safe to remove. + std::fs::remove_dir_all(backup_directory).map_err(|error| { + format!("a stale recovery backup could not be removed: {error}") + })?; + return Ok(()); + } + if backup_digest == approved_digest { + // Restore the approved bytes over the interrupted ones. + if backup_version_directory.exists() { + if let Err(error) = std::fs::remove_dir_all(&backup_version_directory) { + return refuse(format!( + "a recovery backup exists but the interrupted version could not be removed: {error}" + )); + } + } + if let Err(error) = std::fs::rename(backup_directory, &backup_version_directory) { + return refuse(format!( + "the recovery backup could not be restored: {error}" + )); + } + return Ok(()); + } + return refuse(format!( + "an interrupted install left an ambiguous state: neither the version directory nor \ + its recovery backup matches the approved digest recorded in the installation \ + record; the backup at {} and any interrupted bytes were kept", + backup_directory.display() + )); + } + // The durable record approves a different version: this backup is an + // orphan. It may only be cleared once the approved bytes are provably + // intact in their own directory, and it is restored there instead when + // it provably holds them. + let approved_directory = install_root.join(&durable.plugin_id).join(&durable.version); + let approved_digest_matches = approved_directory.exists() + && super::signature::content_digest(&approved_directory) + .map(|digest| digest == approved_digest) + .unwrap_or(false); + if !approved_digest_matches && backup_digest == approved_digest { + // The approved bytes only exist in this backup: restore them to + // their own directory before publishing another version. + std::fs::rename(backup_directory, &approved_directory).map_err(|error| { + format!( + "the recovery backup could not be restored to {}: {error}", + approved_directory.display() + ) + })?; + } else if !approved_digest_matches { + return refuse(format!( + "an interrupted install left an ambiguous state: the approved version {} is missing \ + or does not match its record digest, so the recovery backup cannot be judged; the \ + backup at {} was kept", + durable.version, + backup_directory.display() + )); + } + if backup_directory.exists() { + std::fs::remove_dir_all(backup_directory) + .map_err(|error| format!("a stale recovery backup could not be removed: {error}"))?; + } + Ok(()) +} + +fn activate_staged_version_and_record( staging_directory: &Path, version_directory: &Path, + install_root: &Path, + version: &str, + record: &InstallationRecord, + cleanup_backup: &dyn Fn(&Path) -> std::io::Result<()>, ) -> Result<(), String> { + let record_path = install_root + .join(&record.plugin_id) + .join("installation.json"); + let temp_path = record_path.with_extension("json.tmp"); + std::fs::write( + &temp_path, + serde_json::to_string_pretty(record).map_err(|error| error.to_string())?, + ) + .map_err(|error| format!("could not stage the installation record: {error}"))?; + // The backup carries the full version name: adjacent patch versions + // (1.0.0 and 1.0.1) must never share one recovery path. Leftovers were + // already resolved before staging; a backup appearing here would mean a + // concurrent writer and is refused instead of deleted or restored. + let plugin_directory = install_root.join(&record.plugin_id); + let backup_directory = plugin_directory.join(format!("{version}.previous-tmp")); + if backup_directory.exists() { + let _ = std::fs::remove_file(&temp_path); + return Err(format!( + "a recovery backup for {version} appeared during the transaction; the state was kept" + )); + } if !version_directory.exists() { - return std::fs::rename(staging_directory, version_directory) - .map_err(|error| format!("activation failed: {error}")); + // First install: activate, then commit. No previous version exists, + // so a failed commit removes the new bytes again. + if let Err(activation_error) = std::fs::rename(staging_directory, version_directory) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!("activation failed: {activation_error}")); + } + if let Err(record_error) = std::fs::rename(&temp_path, &record_path) { + let removal_error = std::fs::remove_dir_all(version_directory).err(); + let _ = std::fs::remove_file(&temp_path); + return Err(match removal_error { + None => format!( + "could not write the installation record ({record_error}); the new version was removed" + ), + Some(removal_error) => format!( + "could not write the installation record ({record_error}) and the new version could not be removed ({removal_error}); the record still names no installed version" + ), + }); + } + return Ok(()); } - // Replacing an existing version: move the current directory aside first, - // and restore it if activation fails, so a failed same-version reinstall - // never leaves the install record pointing at missing bytes. - let backup_directory = version_directory.with_extension("previous-tmp"); - if backup_directory.exists() { - std::fs::remove_dir_all(&backup_directory).map_err(|error| error.to_string())?; + // Replacing an existing version: move the current directory aside first + // and keep it until the record has committed. + if let Err(backup_error) = std::fs::rename(version_directory, &backup_directory) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!( + "could not back up the current version: {backup_error}" + )); } - std::fs::rename(version_directory, &backup_directory) - .map_err(|error| format!("could not back up the current version: {error}"))?; if let Err(activation_error) = std::fs::rename(staging_directory, version_directory) { - if let Err(restore_error) = std::fs::rename(&backup_directory, version_directory) { - return Err(format!( + let restore_error = std::fs::rename(&backup_directory, version_directory).err(); + let _ = std::fs::remove_file(&temp_path); + return Err(match restore_error { + Some(restore_error) => format!( "activation failed ({activation_error}) and the previous version could not be restored ({restore_error})" - )); - } - return Err(format!("activation failed: {activation_error}")); + ), + None => format!("activation failed: {activation_error}"), + }); } - let _ = std::fs::remove_dir_all(&backup_directory); + if let Err(record_error) = std::fs::rename(&temp_path, &record_path) { + // The old record still names the old version, so the old bytes must + // come back; a failed restore is reported, never swallowed. + let restore_result = (|| { + std::fs::remove_dir_all(version_directory)?; + std::fs::rename(&backup_directory, version_directory)?; + Ok::<(), std::io::Error>(()) + })(); + let _ = std::fs::remove_file(&temp_path); + return Err(match restore_result { + Ok(()) => format!( + "could not write the installation record ({record_error}); the previous version was restored" + ), + Err(restore_error) => format!( + "could not write the installation record ({record_error}) and the previous version could not be restored ({restore_error}); the recovery backup at {} was kept for the next attempt", + backup_directory.display() + ), + }); + } + // Transaction committed: the backup is best-effort cleanup from here. + let _ = cleanup_backup(&backup_directory); Ok(()) } +/// Verifies a rollback candidate the way it was originally installed: the +/// restored package must match its record, validate, and satisfy the trust +/// policy with its own provenance. The candidate lives in the install root, +/// so a bundled claim can never be honored here. +pub(crate) fn verify_rollback_candidate( + previous_directory: &Path, + plugin_id: &str, + previous_version: &str, + origin: super::signature::InstallOrigin, + expected_content_digest: Option<&str>, +) -> Result<(), String> { + let manifest = read_package_manifest(previous_directory)?; + if manifest["id"].as_str() != Some(plugin_id) + || manifest["version"].as_str() != Some(previous_version) + { + return Err("the previous package does not match its installation record".to_string()); + } + validate_manifest(&manifest).map_err(|rejection| rejection.message)?; + super::signature::verify_for_origin( + previous_directory, + plugin_id, + previous_version, + &manifest, + origin, + expected_content_digest, + None, + &super::signature::trusted_keys(), + ) + .map_err(|error| error.message()) +} + /// Reads the current install record of a plugin; `None` when the plugin has /// no record yet or the record is unreadable, which a fresh install then /// replaces with a complete record. @@ -629,7 +1093,7 @@ fn read_package_manifest(package_root: &Path) -> Result String { +fn manifest_replay_digest(manifest: &serde_json::Value) -> String { let mut hasher = Sha256::new(); hasher.update(manifest.to_string().as_bytes()); format!("{:x}", hasher.finalize()) diff --git a/windows/tauri/src-tauri/src/plugin_host/install_tests.rs b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs index df615e4abf..b082abb94e 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install_tests.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs @@ -11,9 +11,31 @@ mod install_tests { use crate::plugin_host::signature::{self, InstallOrigin}; use crate::plugin_host::store::{self, DiskProvenance, InstallationRecord}; use crate::plugin_host::tests::read_fixture_for_tests; + use serde_json::json; use std::path::PathBuf; - fn unique_temp_root(label: &str) -> PathBuf { + struct TestRoot(PathBuf); + + impl std::ops::Deref for TestRoot { + type Target = PathBuf; + fn deref(&self) -> &PathBuf { + &self.0 + } + } + + impl AsRef for TestRoot { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } + } + + impl Drop for TestRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn unique_temp_root(label: &str) -> TestRoot { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system clock") @@ -23,7 +45,7 @@ mod install_tests { std::process::id() )); std::fs::create_dir_all(&root).expect("temp root"); - root + TestRoot(root) } const TEST_PLUGIN_ID: &str = "io.example.acme-php"; @@ -376,13 +398,1439 @@ mod install_tests { &manifest, InstallOrigin::Community, Some(first_digest.as_str()), - signature::trusted_keys(), + None, + &signature::trusted_keys(), ) .expect_err("tampered previous version must be refused"); assert_eq!(error.code(), "plugin.trust.contentChanged"); let _ = std::fs::remove_dir_all(&root); } + /// P1 regression: the installation record is part of the publish + /// transaction. When the record commit fails after the version bytes + /// were swapped (the record file is held open, so the rename onto it + /// fails on Windows), the previous version's bytes and its record must + /// both be restored, with no staging or temp leftovers. + /// A package directory with the declared version kept at `version` even + /// when the source directory name carries a distinguishing suffix, so a + /// same-version reinstall passes manifest validation. + fn same_version_package(root: &std::path::Path, version: &str, suffix: &str) -> PathBuf { + let directory = package_directory(root, &format!("{version}-{suffix}")); + let manifest_path = directory.join("plugin.json"); + let mut manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&manifest_path).expect("manifest readable"), + ) + .expect("manifest is JSON"); + manifest["version"] = serde_json::json!(version); + std::fs::write( + &manifest_path, + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest rewritten"); + directory + } + + #[cfg(windows)] + #[test] + fn record_commit_failure_restores_the_previous_version() { + let root = unique_temp_root("staging-record-restore"); + let install_root = root.join("plugins"); + let approved = package_directory(&root, "1.0.0"); + let original_record = community_record(TEST_PLUGIN_ID, "1.0.0", &approved); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &approved, + original_record.clone(), + ) + .expect("first install succeeds"); + let original_digest = original_record + .disk + .as_ref() + .expect("disk provenance") + .content_digest + .clone(); + let marker = install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/language-server/bin/intelephense.js"); + assert_eq!(std::fs::read(&marker).expect("marker"), b"entry"); + + // A legitimate same-version reinstall: fresh snapshot, fresh review, + // matching digest binding - only the record commit will fail. + let replacement = same_version_package(&root, "1.0.0", "replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + let replacement_record = community_record(TEST_PLUGIN_ID, "1.0.0", &replacement); + assert_ne!( + replacement_record + .disk + .as_ref() + .expect("disk") + .content_digest, + original_digest, + "the replacement must be a different approved byte set" + ); + + // Holding the record file open without FILE_SHARE_DELETE keeps + // Windows from renaming the new record over it, which fails the + // commit after the version bytes were already swapped. + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + let record_path = install_root.join(TEST_PLUGIN_ID).join("installation.json"); + let record_lock = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&record_path) + .expect("record file opened"); + let publish_error = publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + replacement_record, + ) + .expect_err("the record commit must fail while the record is locked"); + drop(record_lock); + assert!( + publish_error.contains("record"), + "the failure must name the record commit: {publish_error}" + ); + + // The old version bytes and the old record are both restored. + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"entry", + "the previous version bytes must be restored" + ); + let stored: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(install_root.join(TEST_PLUGIN_ID).join("installation.json")) + .expect("record"), + ) + .expect("record parses"); + assert_eq!( + stored.disk.as_ref().expect("disk").content_digest, + original_digest, + "the old record must survive the failed transaction" + ); + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let leftovers: Vec = std::fs::read_dir(&plugin_directory) + .expect("plugin directory readable") + .flatten() + .map(|entry| entry.file_name().to_string_lossy().to_string()) + .collect(); + assert!( + leftovers + .iter() + .all(|name| name == "1.0.0" || name == "installation.json"), + "the failed transaction must leave no staging, backup, or temp entries: {leftovers:?}" + ); + + // Failure retry: with the lock released, the same reinstall commits. + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect("the retry succeeds once the record is writable"); + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"replaced", + "the retry installs the new bytes" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// A record that cannot even be staged (a directory blocks the temp + /// path) fails before any version byte moves. + #[test] + fn record_staging_failure_leaves_the_active_version_untouched() { + let root = unique_temp_root("staging-record-preflight"); + let install_root = root.join("plugins"); + let approved = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &approved, + community_record(TEST_PLUGIN_ID, "1.0.0", &approved), + ) + .expect("first install succeeds"); + let marker = install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/language-server/bin/intelephense.js"); + + let replacement = same_version_package(&root, "1.0.0", "replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + // The temp record path is occupied by a directory. + std::fs::create_dir_all( + install_root + .join(TEST_PLUGIN_ID) + .join("installation.json.tmp"), + ) + .expect("blocker planted"); + let publish_error = publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect_err("publish must fail when the record cannot be staged"); + eprintln!("publish error: {publish_error}"); + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"entry", + "the active version must be untouched when the record cannot be staged" + ); + assert!( + publish_error.contains("record"), + "the failure must name the record: {publish_error}" + ); + let stored: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(install_root.join(TEST_PLUGIN_ID).join("installation.json")) + .expect("record"), + ) + .expect("record parses"); + assert_eq!(stored.version, "1.0.0"); + let _ = std::fs::remove_dir_all(&root); + } + + /// A first install whose record commit fails publishes no bytes at all: + /// there is no previous version to fall back to, so the version + /// directory is removed again. + #[test] + fn record_commit_failure_on_first_install_publishes_nothing() { + let root = unique_temp_root("staging-record-first"); + let install_root = root.join("plugins"); + let package = package_directory(&root, "1.0.0"); + // A directory occupies the record path, so the commit cannot land. + std::fs::create_dir_all(install_root.join(TEST_PLUGIN_ID).join("installation.json")) + .expect("record path blocker planted"); + assert!(publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &package, + community_record(TEST_PLUGIN_ID, "1.0.0", &package), + ) + .is_err()); + assert!( + !install_root.join(TEST_PLUGIN_ID).join("1.0.0").exists(), + "a failed first install must not leave version bytes behind" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // ---------- signed disk packages (M1: the disk channel must accept + // publisher-signed packages) and prepare/confirm byte binding ---------- + + /// Writes a signature document over the current package bytes with the + /// shared RFC 8032 test key, exactly like the release signing flow. + fn write_test_signature( + package_root: &std::path::Path, + key_id: &str, + plugin_id: &str, + plugin_version: &str, + signing: &ed25519_dalek::SigningKey, + ) { + use base64::Engine; + use ed25519_dalek::Signer; + let files = signature::file_digests(package_root).expect("package digests"); + let payload = signature::canonical_payload(key_id, plugin_id, plugin_version, &files); + let signature_bytes = signing.sign(&payload); + let document = signature::SignatureDocument { + schema_version: signature::SCHEMA_VERSION, + algorithm: signature::ALGORITHM.to_string(), + key_id: key_id.to_string(), + plugin_id: plugin_id.to_string(), + plugin_version: plugin_version.to_string(), + files, + signature: base64::engine::general_purpose::STANDARD.encode(signature_bytes.to_bytes()), + }; + std::fs::write( + package_root.join(signature::SIGNATURE_FILE_NAME), + serde_json::to_string_pretty(&document).expect("document text"), + ) + .expect("signature document written"); + } + + /// A disk-installable package whose manifest declares the given trust + /// policy; the official-namespace fixture is reshaped the way a signed + /// community package looks. + fn declared_package_directory( + root: &std::path::Path, + plugin_id: &str, + declared: &str, + ) -> PathBuf { + let directory = root.join(format!("pkg-{plugin_id}")); + std::fs::create_dir_all(&directory).expect("package dir"); + let mut manifest = read_fixture_for_tests("language-packaged-server.plugin.json"); + manifest["id"] = serde_json::json!(plugin_id); + manifest["vendor"]["signatureRequirement"] = serde_json::json!(declared); + manifest["version"] = serde_json::json!("1.0.0"); + std::fs::write( + directory.join("plugin.json"), + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest file"); + let server_directory = directory.join("language-server").join("bin"); + std::fs::create_dir_all(&server_directory).expect("server dir"); + std::fs::write(server_directory.join("intelephense.js"), b"entry").expect("entry file"); + directory + } + + fn test_trusted_keys( + signing: &ed25519_dalek::SigningKey, + ) -> std::collections::BTreeMap { + std::collections::BTreeMap::from([("lithe-test-v1".to_string(), signing.verifying_key())]) + } + + /// P1 regression: a publisher-signed package must be installable from + /// disk. The disk channel records community provenance and the recorded + /// digest, and the verified publisher signature satisfies the pinned + /// policy, so the signed package installs and loads. + #[test] + fn signed_disk_package_installs_and_loads() { + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests(test_trusted_keys(&signing), || { + let root = unique_temp_root("signed-disk"); + let source = + declared_package_directory(&root, "io.example.signed-php", "publisherPackage"); + write_test_signature( + &source, + "lithe-test-v1", + "io.example.signed-php", + "1.0.0", + &signing, + ); + + let temporary_root = root.join("plugin-temp"); + let install_root = root.join("plugins"); + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-test-session", + source.to_str().expect("source path"), + ) + .expect("prepare accepts the signed package"); + let confirmed = crate::plugin_host::install::confirm_disk_install_snapshot( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + ) + .expect("confirm accepts the signed package"); + assert_eq!( + confirmed.record.origin, + Some(InstallOrigin::Community), + "the origin stays the real disk channel" + ); + crate::plugin_host::install::publish_installed_version( + &install_root, + &confirmed.plugin_id, + &confirmed.version, + &temporary_root.join(&prepared.session_id), + confirmed.record, + ) + .expect("publish succeeds"); + + let catalog = + crate::plugin_host::scan::build_catalog_from(&root.join("none"), &install_root) + .expect("catalog builds"); + let entry = &catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry") + .entry; + assert_eq!( + entry.status, "accepted", + "the signed disk package must load: {:?}", + entry.rejection + ); + let _ = std::fs::remove_dir_all(&root); + }); + } + + #[test] + fn signed_disk_package_with_an_unknown_key_is_refused() { + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests(test_trusted_keys(&signing), || { + let root = unique_temp_root("signed-unknown-key"); + let source = + declared_package_directory(&root, "io.example.signed-php", "publisherPackage"); + // Signed by a key ID the host does not trust at all. + let stranger = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]); + write_test_signature( + &source, + "stranger-test-key", + "io.example.signed-php", + "1.0.0", + &stranger, + ); + + let temporary_root = root.join("plugin-temp"); + let error = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-test-session", + source.to_str().expect("source path"), + ) + .expect_err("an unknown signing key must be refused"); + assert!(error.contains("trust"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + }); + } + + #[test] + fn signed_disk_package_with_a_bad_signature_is_refused() { + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests(test_trusted_keys(&signing), || { + let root = unique_temp_root("signed-bad"); + let source = + declared_package_directory(&root, "io.example.signed-php", "publisherPackage"); + write_test_signature( + &source, + "lithe-test-v1", + "io.example.signed-php", + "1.0.0", + &signing, + ); + // Tamper after signing. + std::fs::write(source.join("language-server/bin/intelephense.js"), b"evil") + .expect("tampered entry"); + + let temporary_root = root.join("plugin-temp"); + let error = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-test-session", + source.to_str().expect("source path"), + ) + .expect_err("a bad signature must be refused"); + assert!(error.contains("signature"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + }); + } + + #[test] + fn same_team_disk_package_is_refused_even_when_signed() { + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests(test_trusted_keys(&signing), || { + let root = unique_temp_root("signed-same-team"); + let source = declared_package_directory(&root, "io.example.team-php", "sameTeamAsHost"); + write_test_signature( + &source, + "lithe-test-v1", + "io.example.team-php", + "1.0.0", + &signing, + ); + + let temporary_root = root.join("plugin-temp"); + let error = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-test-session", + source.to_str().expect("source path"), + ) + .expect_err("a sameTeamAsHost disk package must be refused"); + assert!(error.contains("inside Lithe"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + }); + } + + /// P1 regression: the approval binds to the bytes the user reviewed. + /// Replacing a package file after prepare (the manifest unchanged) must + /// not install: the frozen session digest no longer matches. + #[test] + fn confirm_rejects_bytes_changed_after_prepare() { + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests(test_trusted_keys(&signing), || { + let root = unique_temp_root("confirm-swap"); + let source = declared_package_directory(&root, "io.example.swap-php", "none"); + let temporary_root = root.join("plugin-temp"); + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-test-session", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + + // Swap a non-manifest file inside the prepared snapshot. + let snapshot = temporary_root.join(&prepared.session_id); + std::fs::write( + snapshot.join("language-server/bin/intelephense.js"), + b"replaced after review", + ) + .expect("snapshot file swapped"); + + let install_root = root.join("plugins"); + let error = crate::plugin_host::install::confirm_disk_install_snapshot( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + ) + .expect_err("confirm must refuse bytes that changed after prepare"); + assert!(error.contains("changed after review"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + }); + } + + /// Adding or removing an ordinary file after prepare is the same + /// rejection: the approved file set is exact. + #[test] + fn confirm_rejects_added_and_removed_files_after_prepare() { + let signing = signature::rfc8032_signing_key_for_tests(); + signature::with_trusted_keys_for_tests(test_trusted_keys(&signing), || { + let root = unique_temp_root("confirm-files"); + let source = declared_package_directory(&root, "io.example.files-php", "none"); + let temporary_root = root.join("plugin-temp"); + let install_root = root.join("plugins"); + + // An added file after prepare. + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-added", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + std::fs::write( + temporary_root.join(&prepared.session_id).join("extra.js"), + b"added after review", + ) + .expect("file added to the snapshot"); + let error = crate::plugin_host::install::confirm_disk_install_snapshot( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + ) + .expect_err("an added file must be refused"); + assert!(error.contains("changed after review"), "{error}"); + + // A removed file after prepare. + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-removed", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + std::fs::remove_file( + temporary_root + .join(&prepared.session_id) + .join("language-server/bin/intelephense.js"), + ) + .expect("file removed from the snapshot"); + let error = crate::plugin_host::install::confirm_disk_install_snapshot( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + ) + .expect_err("a removed file must be refused"); + assert!(error.contains("changed after review"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + }); + } + + /// P1 regression: a rollback candidate can never be honored with + /// bundled provenance. The candidate lives in the install root, so the + /// seal check refuses even a signed, otherwise-valid package. + #[test] + fn rollback_cannot_restore_a_bundled_claiming_previous_version() { + let root = unique_temp_root("rollback-bundled"); + let install_root = root.join("plugins"); + let package = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &package, + community_record(TEST_PLUGIN_ID, "1.0.0", &package), + ) + .expect("installs"); + + // A sameTeamAsHost candidate claimed as bundled is refused by the + // seal check: the candidate lives in the install root, never inside + // the host installation. + let sealed = declared_package_directory(&root, "io.example.sealed-php", "sameTeamAsHost"); + let error = crate::plugin_host::install::verify_rollback_candidate( + &sealed, + "io.example.sealed-php", + "1.0.0", + crate::plugin_host::signature::InstallOrigin::Bundled, + None, + ) + .expect_err("a bundled claim must not be restorable from the install root"); + assert!(error.contains("inside Lithe"), "{error}"); + + // An unsigned candidate claimed as bundled is refused as well: the + // bundled origin cannot legitimize an unsigned package. + let unsigned = declared_package_directory(&root, "io.example.unsigned-php", "none"); + let error = crate::plugin_host::install::verify_rollback_candidate( + &unsigned, + "io.example.unsigned-php", + "1.0.0", + crate::plugin_host::signature::InstallOrigin::Bundled, + None, + ) + .expect_err("a bundled claim must not legitimize an unsigned package"); + assert!(error.contains("community"), "{error}"); + + // A community candidate without its approved digest is refused too. + let error = crate::plugin_host::install::verify_rollback_candidate( + &install_root.join(TEST_PLUGIN_ID).join("1.0.0"), + TEST_PLUGIN_ID, + "1.0.0", + crate::plugin_host::signature::InstallOrigin::Community, + None, + ) + .expect_err("a community candidate needs its approved digest"); + assert!(error.contains("digest"), "{error}"); + + // The real previous provenance restores cleanly. + crate::plugin_host::install::verify_rollback_candidate( + &install_root.join(TEST_PLUGIN_ID).join("1.0.0"), + TEST_PLUGIN_ID, + "1.0.0", + crate::plugin_host::signature::InstallOrigin::Community, + Some( + crate::plugin_host::signature::content_digest(&package) + .expect("digest") + .as_str(), + ), + ) + .expect("the recorded provenance restores"); + let _ = std::fs::remove_dir_all(&root); + } + + // ---------- barrier ordering, cleanup, and recovery-backup handling ---- + + /// P1 regression: the install record is read and the rollback chain is + /// built inside the lifecycle barrier, so a mutation that commits while + /// another confirm is draining is not overwritten with a stale chain. + #[test] + fn record_chain_is_built_inside_the_barrier() { + let root = unique_temp_root("barrier-chain"); + let install_root = root.join("plugins"); + let temporary_root = root.join("plugin-temp"); + let first = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &first, + community_record(TEST_PLUGIN_ID, "1.0.0", &first), + ) + .expect("first install"); + + // The session installs version 3.0.0. + let third = package_directory(&root, "3.0.0"); + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-barrier", + third.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + let snapshot = temporary_root.join(&prepared.session_id); + + // Another mutation commits version 2.0.0 while the confirm drains: + // the barrier closure lands that state before the record is built. + let second_digest = "2".repeat(64); + let first_digest = crate::plugin_host::signature::content_digest(&first).expect("digest"); + let stale_chain_overwritten = std::sync::Arc::new(std::sync::Mutex::new(false)); + let begin = { + let install_root = install_root.clone(); + let stale_chain_overwritten = stale_chain_overwritten.clone(); + move || -> Result<(), String> { + std::fs::write( + install_root.join(TEST_PLUGIN_ID).join("installation.json"), + json!({ + "schemaVersion": 2, + "pluginId": TEST_PLUGIN_ID, + "version": "2.0.0", + "enabled": true, + "origin": "community", + "disk": { + "fileName": "second.zip", + "archiveSHA256": null, + "contentDigest": second_digest, + "approvedPrograms": [] + }, + "previousVersion": "1.0.0", + "previousOrigin": "community", + "previousDisk": { + "fileName": "plugin.zip", + "archiveSHA256": null, + "contentDigest": first_digest, + "approvedPrograms": [] + } + }) + .to_string(), + ) + .expect("concurrent record written"); + *stale_chain_overwritten.lock().expect("flag") = true; + Ok(()) + } + }; + + let confirmed = crate::plugin_host::install::confirm_and_publish_in_barrier( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + &snapshot, + TEST_PLUGIN_ID, + &begin, + ) + .expect("the in-barrier confirm and publish succeed"); + assert!(*stale_chain_overwritten.lock().expect("flag")); + // The chain is derived from the post-barrier record: the 2.0.0 state + // that committed during the drain becomes the rollback target. + assert_eq!(confirmed.record.version, "3.0.0"); + assert_eq!( + confirmed.record.previous_version.as_deref(), + Some("2.0.0"), + "the rollback chain must come from the record as it exists inside the barrier" + ); + assert_eq!( + confirmed + .record + .previous_disk + .as_ref() + .expect("previous disk") + .content_digest, + "2".repeat(64) + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// Barrier refusals and validation refusals both release the session: + /// the snapshot directory and the session record are gone afterwards. + #[test] + fn failed_confirms_release_the_install_session() { + let root = unique_temp_root("confirm-cleanup"); + let install_root = root.join("plugins"); + let temporary_root = root.join("plugin-temp"); + let source = package_directory(&root, "1.0.0"); + + // A barrier that refuses: the session must be released. + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-drain", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + let snapshot = temporary_root.join(&prepared.session_id); + assert!(snapshot.exists(), "the snapshot exists before the confirm"); + let error = crate::plugin_host::install::confirm_and_publish_in_barrier( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + &snapshot, + TEST_PLUGIN_ID, + &|| Err("another mutation is still draining".to_string()), + ) + .expect_err("a refusing barrier must fail the confirm"); + assert!(error.contains("draining"), "{error}"); + assert!( + !snapshot.exists(), + "the snapshot must be released after a barrier refusal" + ); + assert!( + !temporary_root + .join(format!("{}.source.json", prepared.session_id)) + .exists(), + "the session record must be released after a barrier refusal" + ); + + // A validation refusal (corrupted session record) releases too. + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-corrupt", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + let snapshot = temporary_root.join(&prepared.session_id); + std::fs::write( + temporary_root.join(format!("{}.source.json", prepared.session_id)), + "{ not json", + ) + .expect("session record corrupted"); + assert!(crate::plugin_host::install::confirm_and_publish_in_barrier( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + &snapshot, + TEST_PLUGIN_ID, + &|| Ok(()), + ) + .is_err()); + assert!( + !snapshot.exists(), + "the snapshot must be released after a validation refusal" + ); + assert!( + !temporary_root + .join(format!("{}.source.json", prepared.session_id)) + .exists(), + "the session record must be released after a validation refusal" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// Builds the interrupted mid-commit state: the old bytes sit in the + /// recovery backup, the interrupted new bytes occupy the version + /// directory, and the record still names the old version. + fn plant_interrupted_commit( + install_root: &std::path::Path, + replacement: &std::path::Path, + ) -> PathBuf { + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let version_directory = plugin_directory.join("1.0.0"); + let backup_directory = plugin_directory.join("1.0.0.previous-tmp"); + std::fs::rename(&version_directory, &backup_directory).expect("backup move"); + copy_directory_snapshot(replacement, &version_directory).expect("interrupted bytes"); + backup_directory + } + + /// A recovery backup left by a crashed transaction is restored before + /// the next publish replaces anything, and the completed transaction + /// leaves no backup behind. + #[test] + fn stale_recovery_backup_is_restored_before_the_next_publish() { + let root = unique_temp_root("recovery-restore"); + let install_root = root.join("plugins"); + let original = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &original, + community_record(TEST_PLUGIN_ID, "1.0.0", &original), + ) + .expect("first install"); + let marker = install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/language-server/bin/intelephense.js"); + + let replacement = same_version_package(&root, "1.0.0", "replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + let backup_directory = plant_interrupted_commit(&install_root, &replacement); + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"replaced", + "the interrupted bytes occupy the version directory" + ); + assert_eq!( + std::fs::read(backup_directory.join("language-server/bin/intelephense.js")) + .expect("backup marker"), + b"entry", + "the backup holds the old bytes" + ); + + // The next legitimate publish recovers first, then completes. + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect("publish recovers and completes"); + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"replaced", + "the new transaction installs its own bytes" + ); + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let leftovers: Vec = std::fs::read_dir(&plugin_directory) + .expect("plugin directory readable") + .flatten() + .map(|entry| entry.file_name().to_string_lossy().to_string()) + .collect(); + assert!( + leftovers + .iter() + .all(|name| name == "1.0.0" || name == "installation.json"), + "the recovery backup must not survive a completed transaction: {leftovers:?}" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// When the recovery cannot be restored, the backup is kept as the only + /// recoverable copy and the failure says so; releasing the lock lets the + /// next publish recover and complete. + #[cfg(windows)] + #[test] + fn recovery_backup_survives_a_failed_restore_until_the_retry() { + let root = unique_temp_root("recovery-keep"); + let install_root = root.join("plugins"); + let original = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &original, + community_record(TEST_PLUGIN_ID, "1.0.0", &original), + ) + .expect("first install"); + + let replacement = same_version_package(&root, "1.0.0", "replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + let backup_directory = plant_interrupted_commit(&install_root, &replacement); + + // Locking a file inside the backup keeps Windows from renaming the + // backup directory, so the recovery fails and the backup stays. + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + let locked_file = backup_directory.join("language-server/bin/intelephense.js"); + let lock = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&locked_file) + .expect("backup file opened"); + + let error = publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect_err("the recovery must fail while the backup is locked"); + drop(lock); + assert!(error.contains("recovery backup"), "{error}"); + assert!( + backup_directory.exists(), + "the recoverable bytes must be kept, never blindly deleted" + ); + + // The retry recovers the backup and completes the transaction. + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect("the retry recovers and completes"); + assert_eq!( + std::fs::read( + install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/language-server/bin/intelephense.js"), + ) + .expect("marker"), + b"replaced" + ); + // Post-commit backup cleanup is best-effort in production: a leftover + // backup is the documented self-healing state (the next publish + // restores it before replacing anything) and must stay removable. + if backup_directory.exists() { + std::fs::remove_dir_all(&backup_directory) + .expect("a leftover backup stays removable once no handle holds it"); + } + let _ = std::fs::remove_dir_all(&root); + } + + // ---------- final-review regressions: backup recovery direction, + // backup path collisions, and identity checks before publish ---------- + + /// Exercise the backup produced by an actual replacement, rather than + /// planting one at the path the test assumes. An injected post-commit + /// cleanup failure retains that real backup; retrying must keep the new record + /// and new bytes together even when the next record commit fails. + #[cfg(windows)] + #[test] + fn real_replacement_backup_survives_cleanup_and_failed_retry() { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + + let root = unique_temp_root("real-backup-retry"); + let install_root = root.join("plugins"); + let original = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &original, + community_record(TEST_PLUGIN_ID, "1.0.0", &original), + ) + .expect("first install"); + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let marker = plugin_directory.join("1.0.0/language-server/bin/intelephense.js"); + let replacement = same_version_package(&root, "1.0.0", "approved-replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"approved-new", + ) + .expect("replacement bytes"); + let replacement_record = community_record(TEST_PLUGIN_ID, "1.0.0", &replacement); + crate::plugin_host::install::publish_installed_version_with_backup_cleanup( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + replacement_record.clone(), + &|_| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "cleanup blocked", + )) + }, + ) + .expect("replacement commits despite cleanup failure"); + + let backup = plugin_directory.join("1.0.0.previous-tmp"); + assert_eq!( + std::fs::read(backup.join("language-server/bin/intelephense.js")) + .expect("the real transaction uses the complete version in its backup path"), + b"entry" + ); + assert!(!plugin_directory.join("1.0.previous-tmp").exists()); + assert_eq!( + std::fs::read(&marker).expect("active replacement"), + b"approved-new" + ); + + let record_path = plugin_directory.join("installation.json"); + let _record_lock = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&record_path) + .expect("block the next record commit"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &original, + community_record(TEST_PLUGIN_ID, "1.0.0", &original), + ) + .expect_err("the next record commit fails"); + assert_eq!( + std::fs::read(&marker).expect("restored active bytes"), + b"approved-new" + ); + let stored: InstallationRecord = + serde_json::from_slice(&std::fs::read(&record_path).expect("durable record")) + .expect("record parses"); + assert_eq!( + stored + .disk + .as_ref() + .expect("stored provenance") + .content_digest, + replacement_record + .disk + .as_ref() + .expect("approved provenance") + .content_digest + ); + assert!(!backup.exists()); + } + + /// A corrupt record cannot redirect recovery into another directory. + #[test] + fn recovery_refuses_record_paths_outside_the_owning_plugin() { + for forged_id in [true, false] { + let root = unique_temp_root("recovery-owner"); + let install_root = root.join("plugins"); + let package = package_directory(&root, "1.0.0"); + let mut record = community_record(TEST_PLUGIN_ID, "1.0.0", &package); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &package, + record.clone(), + ) + .expect("initial install"); + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let backup = plugin_directory.join("1.0.1.previous-tmp"); + std::fs::rename(plugin_directory.join("1.0.0"), &backup).expect("crash backup"); + if forged_id { + record.plugin_id = "../outside".to_string(); + } else { + record.version = "../../outside/1.0.0".to_string(); + } + let record_path = plugin_directory.join("installation.json"); + let forged_record = serde_json::to_vec(&record).expect("forged record bytes"); + std::fs::write(&record_path, &forged_record).expect("corrupted record"); + let outside = root.join("outside"); + std::fs::create_dir_all(&outside).expect("unrelated directory"); + std::fs::write(outside.join("keep.txt"), b"unrelated user data").expect("sentinel"); + let error = publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &package, + community_record(TEST_PLUGIN_ID, "1.0.0", &package), + ) + .expect_err("a record cannot redirect recovery outside its plugin"); + assert!(error.contains("recovery record"), "{error}"); + assert_eq!( + std::fs::read(&record_path).expect("unchanged record"), + forged_record + ); + assert!( + backup.join("plugin.json").is_file(), + "the backup stays in its own plugin" + ); + assert!( + !outside.join("1.0.0").exists(), + "recovery does not write outside the plugin" + ); + assert_eq!( + std::fs::read(outside.join("keep.txt")).expect("sentinel"), + b"unrelated user data" + ); + } + } + + /// Adjacent patch versions recover their own complete version names. + #[test] + fn adjacent_patch_versions_do_not_collide_on_backup_paths() { + let root = unique_temp_root("patch-collision"); + let install_root = root.join("plugins"); + let first = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &first, + community_record(TEST_PLUGIN_ID, "1.0.0", &first), + ) + .expect("installs 1.0.0"); + + // Interrupt the 1.0.0 bytes into their own named backup. + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let backup_100 = plugin_directory.join("1.0.0.previous-tmp"); + std::fs::rename(plugin_directory.join("1.0.0"), &backup_100).expect("backup 1.0.0"); + + // Publishing 1.0.1 must restore the approved 1.0.0 bytes from the + // backup and then install 1.0.1, leaving both versions intact. The + // record carries the rollback chain confirm builds from the durable + // record. + let patch = package_directory(&root, "1.0.1"); + let mut patch_record = community_record(TEST_PLUGIN_ID, "1.0.1", &patch); + patch_record.previous_version = Some("1.0.0".to_string()); + patch_record.previous_origin = Some(InstallOrigin::Community); + patch_record.previous_disk = Some(DiskProvenance { + file_name: "first.zip".to_string(), + archive_sha256: None, + content_digest: signature::content_digest(&first).expect("digest"), + approved_programs: Vec::new(), + }); + publish_installed_version(&install_root, TEST_PLUGIN_ID, "1.0.1", &patch, patch_record) + .expect("publishing 1.0.1 recovers 1.0.0 first"); + assert!( + plugin_directory.join("1.0.0/plugin.json").is_file(), + "the approved 1.0.0 bytes are restored to their own directory" + ); + assert!(plugin_directory.join("1.0.1/plugin.json").is_file()); + assert!( + !backup_100.exists(), + "no backup survives a completed recovery" + ); + let stored: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(plugin_directory.join("installation.json")).expect("record"), + ) + .expect("record parses"); + assert_eq!(stored.version, "1.0.1"); + assert_eq!(stored.previous_version.as_deref(), Some("1.0.0")); + // The legacy with_extension naming would have collapsed both patch + // versions onto "1.0.previous-tmp"; it must never appear. + assert!(!plugin_directory.join("1.0.previous-tmp").exists()); + let _ = std::fs::remove_dir_all(&root); + } + + /// A committed transaction that could not delete its backup leaves a + /// stale copy; the next publish removes it instead of restoring old + /// bytes over the new record, and a later record-commit failure still + /// recovers the currently approved bytes. + #[test] + fn leftover_backup_after_a_commit_is_cleaned_and_the_retry_recovers() { + let root = unique_temp_root("stale-backup"); + let install_root = root.join("plugins"); + let original = package_directory(&root, "1.0.0"); + let original_record = community_record(TEST_PLUGIN_ID, "1.0.0", &original); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &original, + original_record.clone(), + ) + .expect("installs"); + let approved_digest = original_record + .disk + .as_ref() + .expect("disk") + .content_digest + .clone(); + let marker = install_root + .join(TEST_PLUGIN_ID) + .join("1.0.0/language-server/bin/intelephense.js"); + + // A stale leftover: bytes that the record does NOT approve. + let stale_backup = install_root.join(TEST_PLUGIN_ID).join("1.0.0.previous-tmp"); + std::fs::create_dir_all(&stale_backup).expect("stale backup dir"); + std::fs::write(stale_backup.join("stale.txt"), b"stale").expect("stale bytes"); + + let replacement = same_version_package(&root, "1.0.0", "replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + + // Block the record commit of the next transaction. + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + let record_path = install_root.join(TEST_PLUGIN_ID).join("installation.json"); + let lock = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&record_path) + .expect("record file opened"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect_err("the record commit must fail while the record is locked"); + drop(lock); + + // The stale backup was removed (the approved bytes were intact), and + // the failed commit restored the approved bytes - not the stale ones. + assert_eq!( + std::fs::read(&marker).expect("marker"), + b"entry", + "the approved bytes are restored, never the stale backup" + ); + let stored: InstallationRecord = + serde_json::from_str(&std::fs::read_to_string(&record_path).expect("record")) + .expect("record parses"); + assert_eq!( + stored.disk.as_ref().expect("disk").content_digest, + approved_digest, + "the durable record still approves the restored bytes" + ); + assert!( + !stale_backup.exists(), + "the stale backup was cleaned before the transaction" + ); + + // The retry completes the replacement. + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect("the retry completes"); + assert_eq!(std::fs::read(&marker).expect("marker"), b"replaced"); + let _ = std::fs::remove_dir_all(&root); + } + + /// A missing active directory with a backup present is judged by the + /// durable record's approved digest: matching bytes are restored, + /// non-matching bytes are refused with everything kept. + #[test] + fn missing_active_with_backup_restores_only_the_approved_bytes() { + let root = unique_temp_root("missing-active"); + let install_root = root.join("plugins"); + let original = package_directory(&root, "1.0.0"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &original, + community_record(TEST_PLUGIN_ID, "1.0.0", &original), + ) + .expect("installs"); + let plugin_directory = install_root.join(TEST_PLUGIN_ID); + let backup = plugin_directory.join("1.0.0.previous-tmp"); + std::fs::rename(plugin_directory.join("1.0.0"), &backup).expect("crash move"); + assert!(!plugin_directory.join("1.0.0").exists(), "active missing"); + + let replacement = same_version_package(&root, "1.0.0", "replacement"); + std::fs::write( + replacement.join("language-server/bin/intelephense.js"), + b"replaced", + ) + .expect("replacement entry"); + + // Ambiguous: the backup does not hold the approved bytes. The + // publish is refused and every byte is kept. + std::fs::write( + backup.join("language-server/bin/intelephense.js"), + b"unprovable", + ) + .expect("backup bytes changed"); + assert!(publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .is_err()); + assert!( + backup.join("language-server/bin/intelephense.js").is_file(), + "the ambiguous backup is kept" + ); + assert!( + !plugin_directory.join("1.0.0").exists(), + "no bytes are invented for the missing active directory" + ); + + // Restore the approved content: the publish recovers and completes. + std::fs::write(backup.join("language-server/bin/intelephense.js"), b"entry") + .expect("approved bytes restored into the backup"); + publish_installed_version( + &install_root, + TEST_PLUGIN_ID, + "1.0.0", + &replacement, + community_record(TEST_PLUGIN_ID, "1.0.0", &replacement), + ) + .expect("the recovery completes the publish"); + assert_eq!( + std::fs::read(plugin_directory.join("1.0.0/language-server/bin/intelephense.js"),) + .expect("marker"), + b"replaced" + ); + assert!(!backup.exists(), "no backup survives the recovery"); + let _ = std::fs::remove_dir_all(&root); + } + + /// A malformed session fails during identity resolution, before the + /// barrier is ever taken. The command resolves the identity inside its + /// cleanup-guarded block, so this component regression pins both halves: + /// the resolution refuses with a diagnosable error, and the shared + /// release helper clears the snapshot and the session record. + #[test] + fn early_identity_failure_releases_the_session() { + let root = unique_temp_root("identity-early"); + let temporary_root = root.join("plugin-temp"); + let source = package_directory(&root, "1.0.0"); + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-badjson", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + let snapshot = temporary_root.join(&prepared.session_id); + std::fs::write(snapshot.join("plugin.json"), "{ not json").expect("manifest corrupted"); + + // The command's first in-guard step refuses a malformed session. + let error = crate::plugin_host::install::session_plugin_id_for_tests( + &temporary_root, + &prepared.session_id, + ) + .expect_err("a malformed session must fail identity resolution"); + assert!(error.contains("plugin.json"), "{error}"); + + // Everything the confirm would have published is released. + crate::plugin_host::install::discard_install_session_for_tests( + &temporary_root, + &prepared.session_id, + &snapshot, + ); + assert!( + !snapshot.exists(), + "the snapshot is released by the early failure" + ); + assert!( + !temporary_root + .join(format!("{}.source.json", prepared.session_id)) + .exists(), + "the session record is released by the early failure" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// If the session content changes to another plugin while the confirm + /// waits for the barrier, the identity check refuses before any bytes + /// are published under the wrong plugin's barrier. + #[test] + fn identity_change_is_refused_before_publish() { + let root = unique_temp_root("identity-swap"); + let install_root = root.join("plugins"); + let temporary_root = root.join("plugin-temp"); + let source = package_directory(&root, "1.0.0"); + let prepared = crate::plugin_host::install::prepare_disk_install_snapshot( + &temporary_root, + "lithe-plugin-disk-swap", + source.to_str().expect("source path"), + ) + .expect("prepare succeeds"); + let snapshot = temporary_root.join(&prepared.session_id); + + let barrier_taken = std::sync::Arc::new(std::sync::Mutex::new(false)); + let begin = { + let barrier_taken = barrier_taken.clone(); + move || -> Result<(), String> { + *barrier_taken.lock().expect("flag") = true; + Ok(()) + } + }; + // The expected identity is what the caller's barrier was taken for; + // the current session is otherwise fully valid and approved for B. + // Only comparing it to the actual barrier's A identity may reject + // this, rather than an earlier manifest-digest mismatch masking it. + let error = crate::plugin_host::install::confirm_and_publish_in_barrier( + &install_root, + &temporary_root, + &prepared.session_id, + &prepared.manifest_digest, + &snapshot, + "io.example.other-barrier-owner", + &begin, + ) + .expect_err("an identity change must be refused before publish"); + assert!(error.contains("changed during confirmation"), "{error}"); + assert!( + *barrier_taken.lock().expect("flag"), + "the barrier was taken for the prepared identity before the check" + ); + assert!( + !install_root.join(TEST_PLUGIN_ID).exists(), + "nothing is published for the prepared plugin" + ); + assert!( + !install_root.join("io.example.other-barrier-owner").exists(), + "nothing is published for the swapped-in plugin" + ); + assert!( + !snapshot.exists(), + "the failed session releases its snapshot" + ); + let _ = std::fs::remove_dir_all(&root); + } + /// The record shape survives serde round-trips with all provenance /// fields intact; the scan and launch paths rely on this. #[test] diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs index aea861013f..18536aa55c 100644 --- a/windows/tauri/src-tauri/src/plugin_host/launch.rs +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -128,6 +128,9 @@ pub(crate) fn verified_package_by_id( .trust .as_ref() .expect("accepted packages carry trust evidence"); + // Bundled packages must still sit inside the real resource root; the + // launch path re-proves the seal instead of trusting the scan's word. + let bundled_root = store::bundled_root(app).ok(); super::signature::verify_for_origin( &package.root, &package.entry.plugin_id, @@ -135,7 +138,8 @@ pub(crate) fn verified_package_by_id( &package.manifest, trust.origin, trust.expected_content_digest.as_deref(), - super::signature::trusted_keys(), + bundled_root.as_deref(), + &super::signature::trusted_keys(), ) .map_err(|error| format!("{} trust check failed: {}", plugin_id, error.message()))?; Ok(package) diff --git a/windows/tauri/src-tauri/src/plugin_host/scan.rs b/windows/tauri/src-tauri/src/plugin_host/scan.rs index 35186bd466..15a2dc80fe 100644 --- a/windows/tauri/src-tauri/src/plugin_host/scan.rs +++ b/windows/tauri/src-tauri/src/plugin_host/scan.rs @@ -24,10 +24,16 @@ pub fn build_catalog_from( ) -> Result, String> { let mut discovered = store::discover_bundled_in(bundled_root)?; discovered.extend(store::discover_installed_in(install_root)?); - Ok(discovered.into_iter().map(scan_package).collect()) + Ok(discovered + .into_iter() + .map(|package| scan_package(package, Some(bundled_root))) + .collect()) } -fn scan_package(package: DiscoveredPackage) -> ScannedPackage { +fn scan_package( + package: DiscoveredPackage, + bundled_root: Option<&std::path::Path>, +) -> ScannedPackage { let fallback_id = package .root .file_name() @@ -79,7 +85,8 @@ fn scan_package(package: DiscoveredPackage) -> ScannedPackage { &manifest, origin, package.content_digest.as_deref(), - super::signature::trusted_keys(), + bundled_root, + &super::signature::trusted_keys(), ) { return quarantined( package, @@ -281,6 +288,9 @@ pub(crate) fn validate_manifest(manifest: &Value) -> Result ScannedPackage { - scan_package(package) +pub(crate) fn scan_package_for_tests( + package: super::store::DiscoveredPackage, + bundled_root: Option<&std::path::Path>, +) -> ScannedPackage { + scan_package(package, bundled_root) } diff --git a/windows/tauri/src-tauri/src/plugin_host/signature.rs b/windows/tauri/src-tauri/src/plugin_host/signature.rs index 576c4f82b9..55ce94409f 100644 --- a/windows/tauri/src-tauri/src/plugin_host/signature.rs +++ b/windows/tauri/src-tauri/src/plugin_host/signature.rs @@ -183,23 +183,72 @@ pub fn declared_signature_requirement( /// The host's trust keys, keyed by key ID. A malformed embedded key is a /// build defect and must never be silently skipped. -pub fn trusted_keys() -> &'static BTreeMap { - static KEYS: OnceLock> = OnceLock::new(); - KEYS.get_or_init(|| { - let encoded = OFFICIAL_PUBLIC_KEY_BASE64; - let bytes = STANDARD_BASE64 - .decode(encoded) - .expect("embedded official plugin signing key must be valid base64"); - let key = VerifyingKey::from_bytes( - &bytes - .try_into() - .expect("embedded official plugin signing key must be 32 bytes"), - ) - .expect("embedded official plugin signing key must be a valid Ed25519 key"); - BTreeMap::from([(OFFICIAL_KEY_ID.to_string(), key)]) +pub fn trusted_keys() -> BTreeMap { + #[cfg(test)] + if let Some(keys) = test_trusted_keys() { + return keys; + } + OFFICIAL_TRUSTED_KEYS + .get_or_init(|| { + let bytes = STANDARD_BASE64 + .decode(OFFICIAL_PUBLIC_KEY_BASE64) + .expect("embedded official plugin signing key must be valid base64"); + let key = VerifyingKey::from_bytes( + &bytes + .try_into() + .expect("embedded official plugin signing key must be 32 bytes"), + ) + .expect("embedded official plugin signing key must be a valid Ed25519 key"); + BTreeMap::from([(OFFICIAL_KEY_ID.to_string(), key)]) + }) + .clone() +} + +static OFFICIAL_TRUSTED_KEYS: OnceLock> = OnceLock::new(); + +#[cfg(test)] +thread_local! { + static TEST_TRUSTED_KEYS: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// Runs `run` with the given keys trusted by the host. Production builds +/// never see this seam; the signature vectors' RFC 8032 key must never trust +/// a real package. +#[cfg(test)] +pub(crate) fn with_trusted_keys_for_tests( + keys: BTreeMap, + run: impl FnOnce() -> R, +) -> R { + TEST_TRUSTED_KEYS.with(|cell| { + let previous = cell.borrow_mut().replace(keys); + let result = run(); + *cell.borrow_mut() = previous; + result }) } +#[cfg(test)] +fn test_trusted_keys() -> Option> { + TEST_TRUSTED_KEYS.with(|cell| cell.borrow().clone()) +} + +/// The RFC 8032 test signing key shared with `signature-vectors.json`. +#[cfg(test)] +pub(crate) fn rfc8032_signing_key_for_tests() -> ed25519_dalek::SigningKey { + fn hex_to_bytes(hex: &str) -> Vec { + (0..hex.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&hex[index..index + 2], 16).expect("hex byte")) + .collect() + } + let mut seed = [0u8; 32]; + seed.copy_from_slice(&hex_to_bytes( + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + )); + ed25519_dalek::SigningKey::from_bytes(&seed) +} + /// True when the package root carries a signature document. pub fn has_signature_file(package_root: &Path) -> bool { package_root.join(SIGNATURE_FILE_NAME).is_file() @@ -292,32 +341,66 @@ pub fn verify_for_install( plugin_version: &str, manifest: &serde_json::Value, origin: InstallOrigin, + bundled_root: Option<&Path>, trusted_keys: &BTreeMap, ) -> Result<(), TrustError> { let declared = declared_signature_requirement(manifest)?; let pinned = official_signature_requirement(plugin_id).unwrap_or(declared); - // Unsigned packages are community packages and nothing else: any other - // origin, or a host-pinned official ID, needs a real signature. - if declared == SignatureRequirement::Unsigned || origin == InstallOrigin::Community { - let allowed = declared == SignatureRequirement::Unsigned - && origin == InstallOrigin::Community - && official_signature_requirement(plugin_id).is_none(); - if !allowed { - return Err(TrustError::UnsignedNotAllowed); + // The pinned host policy decides which channel the package may use; the + // package cannot pick a weaker one by declaring it. + match pinned { + SignatureRequirement::Unsigned => { + // Unsigned trust is community-only: the user's explicit review of + // the exact bytes, never a pinned official ID or another origin. + if origin != InstallOrigin::Community { + return Err(TrustError::UnsignedNotAllowed); + } + verify_signature_if_present(package_root, plugin_id, plugin_version, trusted_keys) + } + SignatureRequirement::PublisherPackage => { + // A publisher signature is required and verified whatever channel + // delivered the package: a marketplace download, or a signed disk + // import recorded as community provenance. + if !has_signature_file(package_root) { + return Err(TrustError::SignatureMissing); + } + verify_signature_if_present(package_root, plugin_id, plugin_version, trusted_keys) + } + SignatureRequirement::SameTeamAsHost => { + // Trusted only inside the host's own installation, proven by real + // containment in the trusted bundled root - never by a record's + // origin claim, and never by a publisher signature alone. + verify_signature_if_present(package_root, plugin_id, plugin_version, trusted_keys)?; + verify_bundled_seal(package_root, origin, bundled_root) } - return verify_signature_if_present(package_root, plugin_id, plugin_version, trusted_keys); - } - if pinned == SignatureRequirement::PublisherPackage && !has_signature_file(package_root) { - return Err(TrustError::SignatureMissing); } - verify_signature_if_present(package_root, plugin_id, plugin_version, trusted_keys)?; - if pinned == SignatureRequirement::PublisherPackage { - return Ok(()); +} + +/// Proves a `sameTeamAsHost` package really lives inside the host's own +/// installation: the origin must be bundled and the package root must sit +/// inside the trusted resource root the discoverer reported. +fn verify_bundled_seal( + package_root: &Path, + origin: InstallOrigin, + bundled_root: Option<&Path>, +) -> Result<(), TrustError> { + if origin != InstallOrigin::Bundled { + return Err(TrustError::HostSealRequired); } - if origin == InstallOrigin::Bundled { - return Ok(()); + let Some(bundled_root) = bundled_root else { + return Err(TrustError::HostSealRequired); + }; + let bundled = bundled_root + .canonicalize() + .map_err(|_| TrustError::HostSealRequired)?; + let package = package_root + .canonicalize() + .map_err(|_| TrustError::HostSealRequired)?; + if package.starts_with(&bundled) { + Ok(()) + } else { + Err(TrustError::HostSealRequired) } - Err(TrustError::HostSealRequired) } /// Full load-time trust check: the host policy decides what the package must @@ -334,6 +417,7 @@ pub fn verify_for_origin( manifest: &serde_json::Value, origin: InstallOrigin, expected_content_digest: Option<&str>, + bundled_root: Option<&Path>, trusted_keys: &BTreeMap, ) -> Result<(), TrustError> { verify_for_install( @@ -342,9 +426,16 @@ pub fn verify_for_origin( plugin_version, manifest, origin, + bundled_root, trusted_keys, )?; - if origin == InstallOrigin::Community { + // Signed or not, the load path rechecks the package against the digest + // recorded when its bytes were approved; a publisher signature proves + // who released the bytes, not that these bytes are what the user saw. + if matches!( + origin, + InstallOrigin::Community | InstallOrigin::Marketplace + ) { let expected = expected_content_digest.ok_or(TrustError::ProvenanceMissing)?; let actual = content_digest(package_root)?; if actual != expected { @@ -526,9 +617,7 @@ mod tests { const RFC8032_EMPTY_SIGNATURE_HEX: &str = "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"; fn test_signing_key() -> SigningKey { - let mut seed = [0u8; 32]; - seed.copy_from_slice(&hex_to_bytes(RFC8032_SEED_HEX)); - SigningKey::from_bytes(&seed) + rfc8032_signing_key_for_tests() } fn hex_to_bytes(hex: &str) -> Vec { @@ -622,6 +711,86 @@ mod tests { assert_eq!(String::from_utf8(decoded).expect("UTF-8"), path); } + /// A `sameTeamAsHost` package is only honored for a bundled origin whose + /// root is provably inside the trusted resource root; a record's claim + /// alone, or a root outside the installation, is a refused seal. + #[test] + fn bundled_origin_requires_a_verified_bundled_root() { + let manifest = serde_json::json!({ + "vendor": { "signatureRequirement": "sameTeamAsHost" } + }); + let root = std::env::temp_dir().join(format!( + "lithe-sig-seal-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let package_root = root.join("OfficialPlugins").join("pkg"); + std::fs::create_dir_all(&package_root).expect("package dir"); + let outside_root = + std::env::temp_dir().join(format!("lithe-sig-seal-outside-{}", std::process::id())); + let keys = trusted_keys(); + + // No trusted root at all: a launch path that cannot prove the seal + // refuses instead of guessing. + assert_eq!( + verify_for_install( + &package_root, + "dev.lithe.plugin.php-support", + "1.0.0", + &manifest, + InstallOrigin::Bundled, + None, + &keys + ), + Err(TrustError::HostSealRequired) + ); + // A trusted root that does not contain the package. + assert_eq!( + verify_for_install( + &package_root, + "dev.lithe.plugin.php-support", + "1.0.0", + &manifest, + InstallOrigin::Bundled, + Some(&outside_root), + &keys + ), + Err(TrustError::HostSealRequired) + ); + // The real containment proof. + assert_eq!( + verify_for_install( + &package_root, + "dev.lithe.plugin.php-support", + "1.0.0", + &manifest, + InstallOrigin::Bundled, + Some(&root.join("OfficialPlugins")), + &keys + ), + Ok(()) + ); + // The same package on disk can never claim the seal, even though the + // root would contain it, because the origin is not bundled. + assert_eq!( + verify_for_install( + &package_root, + "dev.lithe.plugin.php-support", + "1.0.0", + &manifest, + InstallOrigin::Community, + Some(&root.join("OfficialPlugins")), + &keys + ), + Err(TrustError::HostSealRequired) + ); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&outside_root); + } + #[test] fn unsafe_package_entries_are_refused() { let root = std::env::temp_dir().join(format!("lithe-sig-unsafe-{}", std::process::id())); diff --git a/windows/tauri/src-tauri/src/plugin_host/store.rs b/windows/tauri/src-tauri/src/plugin_host/store.rs index 1226525f6a..eea9ad88a6 100644 --- a/windows/tauri/src-tauri/src/plugin_host/store.rs +++ b/windows/tauri/src-tauri/src/plugin_host/store.rs @@ -235,22 +235,43 @@ pub fn discover_installed_in(install_root: &Path) -> Result, content_digest: Option, ) -> super::ScannedPackage { - super::scan::scan_package_for_tests(DiscoveredPackage { - root: root.to_path_buf(), - source: "installed", - enabled: true, - origin, - content_digest, - record_problem, - }) + super::scan::scan_package_for_tests( + DiscoveredPackage { + root: root.to_path_buf(), + source: "installed", + enabled: true, + origin, + content_digest, + record_problem, + }, + None, + ) } /// Writes a schema 2 community record naming `version`, with the content @@ -491,6 +494,85 @@ fn copy_directory_snapshot_for_tests(source: &std::path::Path, target: &std::pat } } +/// P1 regression: an installed record can never claim bundled origin. The +/// bundled identity comes only from the trusted resource discoverer; a +/// schema 2 record that claims it (for the active or the previous version) +/// is forged and the package is quarantined instead of scanned. +#[test] +fn installed_records_cannot_claim_bundled_origin() { + let root = unique_temp_root("trust-bundled-claim"); + let install_root = root.join("plugins"); + let plugin_directory = install_root.join("dev.lithe.plugin.php-support"); + let version_directory = plugin_directory.join("1.0.0"); + std::fs::create_dir_all(&version_directory).expect("version dir"); + let mut manifest = read_fixture("language-packaged-server.plugin.json"); + manifest["id"] = serde_json::json!("dev.lithe.plugin.php-support"); + manifest["vendor"]["signatureRequirement"] = serde_json::json!("sameTeamAsHost"); + manifest["version"] = serde_json::json!("1.0.0"); + std::fs::write( + version_directory.join("plugin.json"), + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest file"); + std::fs::write(version_directory.join("server.js"), b"entry").expect("package file"); + + // The forged record claims bundled origin with no digest at all. + std::fs::write( + plugin_directory.join("installation.json"), + json!({ + "schemaVersion": 2, + "pluginId": "dev.lithe.plugin.php-support", + "version": "1.0.0", + "enabled": true, + "origin": "bundled" + }) + .to_string(), + ) + .expect("record file"); + let discovered = store::discover_installed_in(&install_root).expect("discovery works"); + assert!( + discovered[0].record_problem.is_some(), + "a bundled-origin claim must be a record problem" + ); + let catalog = build_catalog_from(&root.join("none"), &install_root).expect("catalog builds"); + let entry = &catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry") + .entry; + assert_eq!(entry.status, "rejected", "the forged package must not load"); + + // The same holds when only the previous version claims bundled origin. + let digest = crate::plugin_host::signature::content_digest(&version_directory) + .expect("digest of the package"); + std::fs::write( + plugin_directory.join("installation.json"), + json!({ + "schemaVersion": 2, + "pluginId": "dev.lithe.plugin.php-support", + "version": "1.0.0", + "enabled": true, + "origin": "community", + "disk": { + "fileName": "plugin.zip", + "archiveSHA256": null, + "contentDigest": digest, + "approvedPrograms": [] + }, + "previousVersion": "0.9.0", + "previousOrigin": "bundled" + }) + .to_string(), + ) + .expect("record file"); + let discovered = store::discover_installed_in(&install_root).expect("discovery works"); + assert!( + discovered[0].record_problem.is_some(), + "a bundled previous-origin claim must be a record problem too" + ); + let _ = std::fs::remove_dir_all(&root); +} + /// The bytes of an installed community package are rechecked against the /// recorded content digest on every scan, so a package tampered while the /// app was not running is quarantined at the next startup instead of loaded. @@ -585,8 +667,11 @@ fn official_ids_reject_unsigned_community_installs() { ); assert_eq!(scanned.entry.status, "rejected"); let rejection = scanned.entry.rejection.as_ref().expect("rejection"); + // The pinned sameTeamAsHost policy can never be honored from disk: the + // package is refused with the seal error even though it declares a + // requirement the host pins. assert_eq!( - rejection.code, "plugin.trust.unsignedNotAllowed", + rejection.code, "plugin.trust.hostSealRequired", "{rejection:?}" ); let _ = std::fs::remove_dir_all(&root); From 672a699190d93bf8d32e20cfcc04eda4c6bc4189 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sun, 25 Oct 2026 15:51:12 +0800 Subject: [PATCH 3/7] feat(windows): integrate plugin sources details and build foundations --- .github/workflows/ci-windows.yml | 26 + docs/ci-builds.md | 4 + scripts/plugin-fixture-coverage.json | 17 +- .../acceptance_driver.py | 1184 ++++++++++++++ .../acceptance_driver_test.py | 160 ++ .../fake-lsp-server.mjs | 146 ++ .../generate_packages.py | 111 ++ scripts/reuse-worktree-resources.mjs | 3 + scripts/test-reuse-worktree-resources.mjs | 14 + .../test-windows-declarative-plugin-host.ps1 | 483 ++++++ scripts/worktree-resources.json | 6 +- shared/i18n/locales/en-US.json | 96 +- shared/i18n/locales/zh-CN.json | 96 +- shared/i18n/messages.json | 687 ++++++++ .../features/build-system-plugins.json | 14 +- .../features/declarative-plugin-contract.json | 9 +- .../features/plugin-detail-tabs.json | 13 +- .../features/plugin-marketplace.json | 16 +- windows/tauri/src-tauri/src/main.rs | 12 + .../tauri/src-tauri/src/plugin_host/build.rs | 340 ++++ .../src/plugin_host/build_registry.rs | 887 ++++++++++ .../src/plugin_host/build_resolver.rs | 1055 ++++++++++++ .../src/plugin_host/build_resolver_tests.rs | 692 ++++++++ .../src-tauri/src/plugin_host/build_tests.rs | 1391 ++++++++++++++++ .../src-tauri/src/plugin_host/details.rs | 825 ++++++++++ .../src/plugin_host/details_tests.rs | 355 ++++ .../src/plugin_host/marketplace_download.rs | 1421 +++++++++++++++++ .../src/plugin_host/marketplace_index.rs | 1175 ++++++++++++++ .../src/plugin_host/marketplace_sources.rs | 495 ++++++ .../plugin_host/marketplace_sources_tests.rs | 585 +++++++ .../tauri/src-tauri/src/plugin_host/mod.rs | 15 + .../src/features/bootstrap/bootstrap-async.ts | 11 + .../build-system-tool-window-actions.ts | 18 + .../build-systems-service.test.ts | 514 ++++++ .../build-systems/build-systems-service.ts | 400 +++++ .../build-systems/build-systems-store.test.ts | 230 +++ .../build-systems/build-systems-store.ts | 244 +++ .../build-systems/build-systems-types.ts | 95 ++ .../components/build-system-pane.test.tsx | 309 ++++ .../components/build-system-pane.tsx | 452 ++++++ .../use-lsp-integration-declarative.test.tsx | 141 ++ .../editor/hooks/use-lsp-integration.ts | 31 +- .../editor/lsp/built-in-language-support.ts | 30 +- .../lsp/declarative-language-support.test.ts | 193 +++ .../lsp/declarative-language-support.ts | 98 ++ .../layout/components/main-layout.tsx | 20 +- .../components/plugin-activity-rail.tsx | 38 + ...eclarative-marketplace-sources-section.tsx | 300 ++++ .../declarative-marketplace-sources.test.ts | 67 + .../declarative-marketplace-sources.test.tsx | 645 ++++++++ .../declarative-marketplace-sources.ts | 68 + .../plugins/declarative-plugin-lifecycle.ts | 8 + .../plugins/declarative-plugins-section.tsx | 36 +- .../src/features/plugins/details/index.ts | 3 + .../details/plugin-details-view.test.tsx | 170 ++ .../plugins/details/plugin-details-view.tsx | 479 ++++++ .../details/plugin-details.service.test.ts | 179 +++ .../plugins/details/plugin-details.service.ts | 153 ++ .../plugins/details/plugin-details.types.ts | 101 ++ .../workspace/services/workspace-lifecycle.ts | 3 + windows/tauri/src/i18n/shared.generated.ts | 188 +++ 61 files changed, 17518 insertions(+), 39 deletions(-) create mode 100644 scripts/plugin-host-acceptance/acceptance_driver.py create mode 100644 scripts/plugin-host-acceptance/acceptance_driver_test.py create mode 100644 scripts/plugin-host-acceptance/fake-lsp-server.mjs create mode 100644 scripts/plugin-host-acceptance/generate_packages.py create mode 100644 scripts/test-windows-declarative-plugin-host.ps1 create mode 100644 windows/tauri/src-tauri/src/plugin_host/build.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/build_registry.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/build_resolver.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/build_resolver_tests.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/build_tests.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/details.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/details_tests.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/marketplace_download.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/marketplace_sources.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/marketplace_sources_tests.rs create mode 100644 windows/tauri/src/features/build-systems/actions/build-system-tool-window-actions.ts create mode 100644 windows/tauri/src/features/build-systems/build-systems-service.test.ts create mode 100644 windows/tauri/src/features/build-systems/build-systems-service.ts create mode 100644 windows/tauri/src/features/build-systems/build-systems-store.test.ts create mode 100644 windows/tauri/src/features/build-systems/build-systems-store.ts create mode 100644 windows/tauri/src/features/build-systems/build-systems-types.ts create mode 100644 windows/tauri/src/features/build-systems/components/build-system-pane.test.tsx create mode 100644 windows/tauri/src/features/build-systems/components/build-system-pane.tsx create mode 100644 windows/tauri/src/features/editor/hooks/use-lsp-integration-declarative.test.tsx create mode 100644 windows/tauri/src/features/editor/lsp/declarative-language-support.test.ts create mode 100644 windows/tauri/src/features/editor/lsp/declarative-language-support.ts create mode 100644 windows/tauri/src/features/plugins/declarative-marketplace-sources-section.tsx create mode 100644 windows/tauri/src/features/plugins/declarative-marketplace-sources.test.ts create mode 100644 windows/tauri/src/features/plugins/declarative-marketplace-sources.test.tsx create mode 100644 windows/tauri/src/features/plugins/declarative-marketplace-sources.ts create mode 100644 windows/tauri/src/features/plugins/details/index.ts create mode 100644 windows/tauri/src/features/plugins/details/plugin-details-view.test.tsx create mode 100644 windows/tauri/src/features/plugins/details/plugin-details-view.tsx create mode 100644 windows/tauri/src/features/plugins/details/plugin-details.service.test.ts create mode 100644 windows/tauri/src/features/plugins/details/plugin-details.service.ts create mode 100644 windows/tauri/src/features/plugins/details/plugin-details.types.ts diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 5429fc0234..9e3f75e13a 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -304,6 +304,32 @@ jobs: node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-plugin-lsp-launch.json -- src/features/editor/lsp/resolve-editor-lsp-launch.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test plugin sources details build models and editor admission + shell: pwsh + timeout-minutes: 12 + run: | + $runner = ".agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs" + # Each file owns its native/DOM mocks. Leading ./ selects an exact + # file, so the .ts source service does not also run its .tsx view. + $suites = @( + "src/features/plugins/declarative-marketplace-sources.test.ts", + "src/features/plugins/declarative-marketplace-sources.test.tsx", + "src/features/plugins/details/plugin-details.service.test.ts", + "src/features/plugins/details/plugin-details-view.test.tsx", + "src/features/build-systems/build-systems-service.test.ts", + "src/features/build-systems/build-systems-store.test.ts", + "src/features/build-systems/components/build-system-pane.test.tsx", + "src/features/editor/lsp/built-in-language-support.test.ts", + "src/features/editor/lsp/declarative-language-support.test.ts", + "src/features/editor/hooks/use-lsp-integration-declarative.test.tsx" + ) + $index = 0 + foreach ($suite in $suites) { + $index += 1 + node $runner --suite-timeout-ms 60000 --report ".artifacts/test-stability/windows-plugin-integration-$index.json" -- "./$suite" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + - name: Test Agent panel and capability gate shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-agent.json -- src/features/agent src/features/editor/stores/buffer-agent-conversation.test.ts src/utils/markdown-links.test.ts src/config/backend-capabilities.test.ts src/i18n src/features/file-system/utils/file-system-drop-controller.test.ts src/ui/icons.test.tsx diff --git a/docs/ci-builds.md b/docs/ci-builds.md index dbc151d0a4..3c28762ef0 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -717,6 +717,10 @@ Java Support(`dev.lithe.plugin.java-support`)是唯一随 Lithe.app 发行 排除路由直接拒绝,由 `scripts/test-reuse-worktree-resources.mjs` 验证,不能给它增加缓存校验器。 如果后续新增可复用资源,必须同步更新注册表、校验器、脚本测试和本节说明。 +Windows 的插件源配置、按源与平台缓存的原始索引、摘要绑定的临时下载和按工作区保存的构建选项, +同属 `plugin-marketplace-state` 排除路由:由运行时平台 adapter 注入用户可写根, +取消或失败清理临时文件,持久配置由当前用户实例管理;没有可复用的版本/架构/工具链 identity, +不得在任何 worktree 复制阶段共享,也不写入发行资源或改变签名及增量更新基线。 生成资源只有在构建流程写入可验证的源码、配置、平台、架构和工具链 identity stamp 后才能加入注册表,不能仅凭目录存在就跨 worktree 复制。 diff --git a/scripts/plugin-fixture-coverage.json b/scripts/plugin-fixture-coverage.json index ebcbbfec6a..8ba9ff88fb 100644 --- a/scripts/plugin-fixture-coverage.json +++ b/scripts/plugin-fixture-coverage.json @@ -136,11 +136,12 @@ ], "windowsEvidence": [ "windows/tauri/src-tauri/src/plugin_host/tests.rs:112 valid_fixtures_validate_without_host_capabilities", - "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections" + "windows/tauri/src-tauri/src/plugin_host/tests.rs:128 host_v0_capabilities_gate_unimplemented_sections", + "windows/tauri/src-tauri/src/plugin_host/build_tests.rs detect_reads_the_static_model_and_describes_options" ], "windowsStatus": "validated-only", "gapStage": "M4", - "gapDetail": "Windows 只有清单验收与能力门控;static 模型检测、选项、任务、同步指纹无宿主接线测试,属声明式构建接入。" + "gapDetail": "真实 Core 与宿主 registry 的静态模型、共享选项、指纹与撤销回归已覆盖此 fixture;生产 buildSystems 能力仍关闭,工具窗口及实际任务/测试执行尚未完整接通,不将内部夹具注入视为可安装产品验收。" }, { "path": "build-tool-path.plugin.json", @@ -284,10 +285,12 @@ "coreEvidence": [ "rust/lithe-core/src/tests/marketplace_plugins.rs:100 index_fixture_cases_match_expected_availability" ], - "windowsEvidence": [], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs refresh_matches_the_shared_standard_answer" + ], "windowsStatus": "indirect-core", "gapStage": "M2", - "gapDetail": "Windows 声明式市场宿主未接(M2);当前无任何 Windows 测试消费可用性判定结果。" + "gapDetail": "Windows 网络模块已读取此 fixture,在本地受控 HTTP 与真实 Core 下校验能力门控标准答案及缓存;尚无生产市场 IPC/列表与安装消费者,保留 M2 产品接线缺口。" }, { "path": "marketplace/index.json", @@ -299,10 +302,12 @@ "rust/lithe-core/src/tests/marketplace_plugins.rs:272 ui_api_availability_requires_an_implemented_platform_host", "rust/lithe-core/src/tests/marketplace_plugins.rs:332 build_provider_versions_require_host_support_before_download" ], - "windowsEvidence": [], + "windowsEvidence": [ + "windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs refresh_matches_the_shared_standard_answer" + ], "windowsStatus": "indirect-core", "gapStage": "M2", - "gapDetail": "Windows 声明式市场宿主未接(M2);索引归一化与可用性只经 Core 命令验证。" + "gapDetail": "Windows 网络模块已读取此 fixture,在本地受控 HTTP 与真实 Core 下校验能力门控标准答案及缓存;尚无生产市场 IPC/列表与安装消费者,保留 M2 产品接线缺口。" }, { "path": "marketplace/install-reviews.json", diff --git a/scripts/plugin-host-acceptance/acceptance_driver.py b/scripts/plugin-host-acceptance/acceptance_driver.py new file mode 100644 index 0000000000..c72c71b65f --- /dev/null +++ b/scripts/plugin-host-acceptance/acceptance_driver.py @@ -0,0 +1,1184 @@ +#!/usr/bin/env python3 +"""Drive the real Windows Tauri/WebView2 declarative-plugin workflow.""" + +from __future__ import annotations + +import argparse +import contextlib +import ctypes +import hashlib +import json +import os +import re +import shutil +import socket +import subprocess +import sys +import time +import urllib.request +from ctypes import wintypes +from pathlib import Path + +from playwright.sync_api import Page, Playwright, sync_playwright + + +PLUGIN_ID = "io.github.lithe-acceptance.host" +DISPLAY_NAME = "Lithe Host Acceptance" +ACCEPTANCE_IDENTIFIER = "app.lithe.windows.host-acceptance" +CREATE_NEW_PROCESS_GROUP = 0x00000200 +CREATE_NO_WINDOW = 0x08000000 +PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +PROCESS_TERMINATE = 0x0001 +SYNCHRONIZE = 0x00100000 +WAIT_TIMEOUT = 258 +kernel32 = ctypes.windll.kernel32 +kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] +kernel32.OpenProcess.restype = wintypes.HANDLE +kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] +kernel32.GetExitCodeProcess.restype = wintypes.BOOL +kernel32.CloseHandle.argtypes = [wintypes.HANDLE] +kernel32.CloseHandle.restype = wintypes.BOOL +kernel32.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 +kernel32.GetProcessTimes.restype = wintypes.BOOL +kernel32.QueryFullProcessImageNameW.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)] +kernel32.QueryFullProcessImageNameW.restype = wintypes.BOOL +kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] +kernel32.WaitForSingleObject.restype = wintypes.DWORD +kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] +kernel32.TerminateProcess.restype = wintypes.BOOL + + +class OwnedProcess: + """A retained handle, rather than a reusable PID, authorizes termination.""" + + def __init__(self, pid: int, handle, creation_time: int, executable: str): + self.pid = pid + self.handle = handle + self.creation_time = creation_time + self.executable = executable + + @classmethod + def capture(cls, pid: int, expected: dict | None = None): + handle = kernel32.OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE | SYNCHRONIZE, False, pid + ) + if not handle: + return None + try: + created, exited, kernel, user = (wintypes.FILETIME() for _ in range(4)) + if not kernel32.GetProcessTimes(handle, ctypes.byref(created), ctypes.byref(exited), ctypes.byref(kernel), ctypes.byref(user)): + return None + creation_time = (created.dwHighDateTime << 32) | created.dwLowDateTime + image = ctypes.create_unicode_buffer(32768) + length = wintypes.DWORD(len(image)) + if not kernel32.QueryFullProcessImageNameW(handle, 0, image, ctypes.byref(length)): + return None + if expected is not None: + # CIM serializes creation times at microsecond precision. + if creation_time // 10 != int(expected.get("CreationTime", -1)) // 10: + return None + if os.path.normcase(image.value) != os.path.normcase(expected.get("ExecutablePath") or ""): + return None + result = cls(pid, handle, creation_time, image.value) + handle = None + return result + finally: + if handle: + kernel32.CloseHandle(handle) + + def running(self) -> bool: + return bool(self.handle) and kernel32.WaitForSingleObject(self.handle, 0) == WAIT_TIMEOUT + + def terminate(self) -> None: + if self.running() and not kernel32.TerminateProcess(self.handle, 1): + if self.running(): + raise RuntimeError(f"could not terminate the owned process handle for {self.pid}") + + def close(self) -> None: + if self.handle: + kernel32.CloseHandle(self.handle) + self.handle = None + + +owned_processes: dict[int, OwnedProcess] = {} +unverified_fixture_pids: set[int] = set() +fixture_processes: dict[int, OwnedProcess] = {} + + +def remember_owned_process(pid: int, expected: dict | None = None) -> OwnedProcess | None: + previous = owned_processes.get(pid) + if previous is not None and previous.running(): + if expected is None or previous.creation_time // 10 == int(expected.get("CreationTime", -1)) // 10: + return previous + return None + process = OwnedProcess.capture(pid, expected) + if process is not None: + if previous is not None: + previous.close() + owned_processes[pid] = process + return process + + +def owned_process_running(pid: int, expected: dict | None = None) -> bool: + process = owned_processes.get(pid) + return process is not None and process.running() and ( + expected is None or process.creation_time // 10 == int(expected.get("CreationTime", -1)) // 10 + ) + + +def process_exists(pid: int) -> bool: + if pid <= 0: + return False + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return False + exit_code = wintypes.DWORD() + try: + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return False + return exit_code.value == 259 # STILL_ACTIVE + finally: + kernel32.CloseHandle(handle) + + +def terminate_process_tree(pid: int, timeout_seconds: float = 10, inspect_descendants: bool = True) -> None: + owner = owned_processes.get(pid) + if owner is None: + if process_exists(pid): + raise RuntimeError(f"refusing to terminate PID {pid} without a retained ownership handle") + return + if not owner.running(): + return + tree = capture_owned_tree(owner) if inspect_descendants else [] + targets = [owner] + [item for item in tree if item is not owner] + # Each termination targets the original kernel object, including if it + # exited between inspection and this call. Never reopen a historical PID. + for process in targets: + process.terminate() + deadline = time.monotonic() + timeout_seconds + while any(process.running() for process in targets) and time.monotonic() < deadline: + time.sleep(0.05) + if any(process.running() for process in targets): + raise RuntimeError(f"owned process tree rooted at {pid} survived cleanup") + + +def run_bounded_process(command: list[str], timeout_seconds: float, *, inspect_descendants: bool = True) -> subprocess.CompletedProcess: + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=CREATE_NEW_PROCESS_GROUP, + ) + owner = remember_owned_process(process.pid) + try: + stdout, stderr = process.communicate(timeout=timeout_seconds) + return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) + except subprocess.TimeoutExpired as error: + terminate_process_tree(process.pid, inspect_descendants=inspect_descendants) + with contextlib.suppress(Exception): + process.communicate(timeout=2) + raise TimeoutError( + f"process exceeded its {timeout_seconds:.1f}s local deadline: {command[0]}" + ) from error + finally: + if owner is not None: + owner.close() + if owned_processes.get(process.pid) is owner: + del owned_processes[process.pid] + + +def wait_until(predicate, timeout_seconds: float, description: str, interval: float = 0.1): + deadline = time.monotonic() + timeout_seconds + last_error = None + while time.monotonic() < deadline: + try: + value = predicate() + if value: + return value + except Exception as error: # noqa: BLE001 - preserve the latest polling diagnostic. + last_error = error + time.sleep(interval) + suffix = f"; last error: {last_error}" if last_error else "" + raise TimeoutError(f"timed out waiting for {description}{suffix}") + + +def hash_tree(root: Path) -> dict[str, str]: + result = {} + for path in sorted(candidate for candidate in root.rglob("*") if candidate.is_file()): + relative = path.relative_to(root).as_posix() + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + result[relative] = digest.hexdigest() + return result + + +def write_json(path: Path, value) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def process_tree(root_pid: int) -> list[dict]: + script = ( + "$items=Get-CimInstance Win32_Process | " + "Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine," + "@{Name='CreationTime';Expression={$_.CreationDate.ToUniversalTime().ToFileTimeUtc()}}; " + f"$pending=@({root_pid}); $seen=@{{}}; $out=@(); " + "while($pending.Count){$id=[int]$pending[0]; " + "$pending=@($pending | Select-Object -Skip 1); " + "if($seen.ContainsKey($id)){continue}; $seen[$id]=$true; " + "$match=@($items | Where-Object ProcessId -eq $id); $out += $match; " + "$pending += @($items | Where-Object ParentProcessId -eq $id | ForEach-Object ProcessId)}; " + "$out | ConvertTo-Json -Compress" + ) + result = run_bounded_process(["powershell", "-NoProfile", "-Command", script], 15, inspect_descendants=False) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "could not inspect the owned process tree") + if not result.stdout.strip(): + return [] + value = json.loads(result.stdout) + return value if isinstance(value, list) else [value] + + +def capture_owned_tree(root: OwnedProcess, records: list[dict] | None = None) -> list[OwnedProcess]: + if not root.running(): + return [] + records = process_tree(root.pid) if records is None else records + root_record = next((item for item in records if int(item["ProcessId"]) == root.pid), None) + if root_record is None or int(root_record.get("CreationTime", -1)) // 10 != root.creation_time // 10: + return [] + pending = {root.pid: root} + accepted = [] + while pending: + parent_pid, parent = pending.popitem() + for record in records: + pid = int(record["ProcessId"]) + if int(record["ParentProcessId"]) != parent_pid or pid == root.pid: + continue + if int(record.get("CreationTime", -1)) // 10 < parent.creation_time // 10: + continue + child = remember_owned_process(pid, record) + if child is not None and child not in accepted: + accepted.append(child) + pending[pid] = child + return accepted + + +class Reporter: + def __init__(self, progress_path: Path | None = None): + self.tests: list[dict] = [] + self.progress_path = progress_path + self.capture_failure = lambda: None + + @contextlib.contextmanager + def step(self, name: str, max_ms: int = 120_000): + started = time.monotonic() + details = "" + status = "passed" + print(f"START {name}", flush=True) + try: + yield + except Exception as error: # noqa: BLE001 - report and re-raise the exact acceptance failure. + status = "failed" + details = f"{type(error).__name__}: {error}" + with contextlib.suppress(Exception): + self.capture_failure() + raise + finally: + self.tests.append( + { + "name": name, + "suite": "plugin-host-acceptance / native-windows-x64", + "status": status, + "durationMs": round((time.monotonic() - started) * 1000, 3), + "details": details, + "maxMs": max_ms, + } + ) + print(f"{status.upper()} {name} ({self.tests[-1]['durationMs']:.0f} ms)", flush=True) + if self.progress_path is not None: + write_json(self.progress_path, {"tests": self.tests}) + + def document_pending( + self, name: str, details: str, suite: str = "plugin-host-acceptance / pending-platforms" + ): + self.tests.append( + { + "name": name, + "suite": suite, + "status": "skipped", + "durationMs": 0, + "details": details, + "maxMs": 1, + } + ) + + +def safe_remove_owned_directory(path: Path, expected_parent: Path, expected_name: str) -> None: + # MSIX redirects an existing AppData leaf to LocalCache; resolving its + # parent separately gives a different physical root. Prove the exact + # caller-owned logical path, then let Windows perform its redirection. + resolved = Path(os.path.abspath(path)) + if resolved.name != expected_name or resolved.parent != Path(os.path.abspath(expected_parent)): + raise RuntimeError(f"refusing to remove non-acceptance directory: {resolved}") + if resolved.is_symlink() or resolved.is_junction(): + raise RuntimeError(f"refusing to remove a linked acceptance directory: {resolved}") + deadline = time.monotonic() + 5 + while resolved.exists(): + try: + shutil.rmtree(resolved) + except OSError: + if time.monotonic() >= deadline: + raise + time.sleep(0.05) + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return listener.getsockname()[1] + + +def cdp_ready(port: int) -> bool: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=0.5) as response: + return response.status == 200 + except Exception: + return False + + +def invoke(page: Page, command: str, arguments: dict | None = None): + return page.evaluate( + """async ([command, args]) => { + if (!window.__TAURI_INTERNALS__?.invoke) throw new Error("Tauri IPC is unavailable"); + let deadline; + try { + return await Promise.race([ + window.__TAURI_INTERNALS__.invoke(command, args ?? {}), + new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error(`IPC deadline exceeded: ${command}`)), 10000); + }), + ]); + } finally { + clearTimeout(deadline); + } + }""", + [command, arguments or {}], + ) + + +class AppSession: + def __init__( + self, + playwright: Playwright, + executable: Path, + workspace: Path, + webview_data: Path, + log_root: Path, + sequence: int, + ): + self.playwright = playwright + self.executable = executable + self.workspace = workspace + self.webview_data = webview_data + self.log_root = log_root + self.sequence = sequence + self.process: subprocess.Popen | None = None + self.browser = None + self.page: Page | None = None + self.stdout_file = None + self.stderr_file = None + self.initial_tree: list[dict] = [] + self.root_owner: OwnedProcess | None = None + self.owned_tree: list[OwnedProcess] = [] + + def start(self) -> Page: + port = free_port() + environment = os.environ.copy() + environment["WEBVIEW2_USER_DATA_FOLDER"] = str(self.webview_data) + environment["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = ( + f"--remote-debugging-port={port} --remote-allow-origins=http://127.0.0.1:{port}" + ) + self.log_root.mkdir(parents=True, exist_ok=True) + self.stdout_file = (self.log_root / f"app-{self.sequence}.stdout.log").open("w", encoding="utf-8") + self.stderr_file = (self.log_root / f"app-{self.sequence}.stderr.log").open("w", encoding="utf-8") + self.process = subprocess.Popen( + [str(self.executable), str(self.workspace)], + cwd=self.executable.parent, + env=environment, + stdout=self.stdout_file, + stderr=self.stderr_file, + creationflags=CREATE_NEW_PROCESS_GROUP, + ) + self.root_owner = remember_owned_process(self.process.pid) + if self.root_owner is None or Path(self.root_owner.executable).resolve() != self.executable.resolve(): + raise RuntimeError("could not retain the isolated application's process identity") + try: + wait_until(lambda: cdp_ready(port), 30, "the isolated WebView2 CDP endpoint") + self.browser = self.playwright.chromium.connect_over_cdp( + f"http://127.0.0.1:{port}", timeout=15_000 + ) + self.page = wait_until(self._tauri_page, 30, "the real Tauri product page") + self.page.wait_for_load_state("domcontentloaded", timeout=15_000) + self.page.wait_for_function("() => Boolean(window.__TAURI_INTERNALS__?.invoke)", timeout=15_000) + runtime_data = Path(invoke(self.page, "plugin:path|resolve_directory", {"directory": 15})) + expected_data = Path(os.environ["LOCALAPPDATA"]) / ACCEPTANCE_IDENTIFIER + if runtime_data.resolve() != expected_data.resolve(): + raise RuntimeError(f"the executable has an unexpected application data root: {runtime_data}") + write_json(self.log_root / f"app-{self.sequence}.identity.json", { + "appLocalData": str(runtime_data), + "expectedAppLocalData": str(expected_data), + }) + self.initial_tree = process_tree(self.process.pid) + self.owned_tree = capture_owned_tree(self.root_owner, self.initial_tree) + return self.page + except Exception: + with contextlib.suppress(Exception): + terminate_process_tree(self.process.pid) + raise + + def _tauri_page(self): + if self.browser is None: + return None + for context in self.browser.contexts: + for candidate in context.pages: + try: + if candidate.evaluate("() => Boolean(window.__TAURI_INTERNALS__?.invoke)"): + return candidate + except Exception: + continue + return None + + def stop(self, require_graceful: bool = True) -> dict: + if self.process is None: + return {"forced": False, "tree": []} + current_tree = process_tree(self.process.pid) if self.root_owner is not None and self.root_owner.running() else [] + if self.root_owner is not None: + self.owned_tree = list(dict.fromkeys(self.owned_tree + capture_owned_tree(self.root_owner, current_tree))) + tree = list({ + int(item["ProcessId"]): item + for item in self.initial_tree + current_tree + }.values()) + forced = False + if self.root_owner is not None and self.root_owner.running() and self.page is not None: + with contextlib.suppress(Exception): + invoke(self.page, "plugin:process|exit", {"code": 0}) + try: + self.process.wait(timeout=20) + except subprocess.TimeoutExpired: + forced = True + terminate_process_tree(self.process.pid) + if self.browser is not None: + with contextlib.suppress(Exception): + self.browser.close() + for stream in (self.stdout_file, self.stderr_file): + if stream is not None: + stream.close() + deadline = time.monotonic() + 10 + survivors = [item for item in self.owned_tree if item.running()] + while survivors and time.monotonic() < deadline: + time.sleep(0.05) + survivors = [item for item in survivors if item.running()] + if survivors: + for item in survivors: + with contextlib.suppress(Exception): + terminate_process_tree(item.pid) + raise RuntimeError(f"owned application process tree left survivors: {[item.pid for item in survivors]}") + if forced and require_graceful: + raise RuntimeError("the isolated Lithe instance required forced termination") + if require_graceful and self.process.returncode != 0: + raise RuntimeError(f"the isolated Lithe instance exited with code {self.process.returncode}") + return {"forced": forced, "tree": tree} + + +user32 = ctypes.windll.user32 +EnumWindowsProc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) +user32.EnumWindows.argtypes = [EnumWindowsProc, wintypes.LPARAM] +user32.EnumChildWindows.argtypes = [wintypes.HWND, EnumWindowsProc, wintypes.LPARAM] +user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] +user32.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] +user32.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT] +user32.GetWindow.restype = wintypes.HWND +user32.IsWindow.argtypes = [wintypes.HWND] +user32.IsWindowVisible.argtypes = [wintypes.HWND] +user32.IsWindowEnabled.argtypes = [wintypes.HWND] +user32.GetDlgCtrlID.argtypes = [wintypes.HWND] +user32.GetDlgCtrlID.restype = ctypes.c_int +user32.GetParent.argtypes = [wintypes.HWND] +user32.GetParent.restype = wintypes.HWND + + +user32.SendMessageTimeoutW.argtypes = [ + wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPCWSTR, + wintypes.UINT, wintypes.UINT, ctypes.POINTER(ctypes.c_size_t), +] +user32.SendMessageTimeoutW.restype = wintypes.LPARAM +user32.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] +user32.PostMessageW.restype = wintypes.BOOL + + +def window_pid(hwnd: int) -> int: + pid = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + return int(pid.value) + + +def window_class(hwnd: int) -> str: + buffer = ctypes.create_unicode_buffer(256) + user32.GetClassNameW(hwnd, buffer, len(buffer)) + return buffer.value + + +def find_file_dialog(app_pid: int): + matches = [] + + @EnumWindowsProc + def callback(hwnd, _): + if user32.IsWindowVisible(hwnd) and window_class(hwnd) == "#32770": + owner = user32.GetWindow(hwnd, 4) # GW_OWNER + if window_pid(hwnd) == app_pid or (owner and window_pid(owner) == app_pid): + matches.append(hwnd) + return True + + user32.EnumWindows(callback, 0) + return matches[-1] if matches else None + + +def child_windows(hwnd: int) -> list[int]: + children = [] + + @EnumWindowsProc + def callback(child, _): + children.append(child) + return True + + user32.EnumChildWindows(hwnd, callback, 0) + return children + + +def select_native_file(app_pid: int, file_path: Path) -> None: + dialog = wait_until(lambda: find_file_dialog(app_pid), 10, "the native package picker") + edits = [ + child + for child in child_windows(dialog) + if window_class(child) == "Edit" and user32.IsWindowVisible(child) and user32.IsWindowEnabled(child) + ] + if not edits: + classes = sorted({window_class(child) for child in child_windows(dialog)}) + raise RuntimeError(f"native package picker exposed no editable filename field; classes={classes}") + filename_edits = [ + edit for edit in edits if user32.GetDlgCtrlID(user32.GetParent(edit)) == 1148 + ] + if not filename_edits: + controls = [{"class": window_class(edit), "id": user32.GetDlgCtrlID(edit), + "parentId": user32.GetDlgCtrlID(user32.GetParent(edit))} for edit in edits] + raise RuntimeError(f"the native filename combo was not found: {controls}") + edit = filename_edits[0] + WM_SETTEXT = 0x000C + message_result = ctypes.c_size_t() + kernel32.SetLastError(0) + if not user32.SendMessageTimeoutW( + edit, WM_SETTEXT, 0, str(file_path), 0x0002, 5000, ctypes.byref(message_result) + ): + error_code = kernel32.GetLastError() + controls = [ + {"class": window_class(child), "id": user32.GetDlgCtrlID(child)} + for child in child_windows(dialog) + ] + raise RuntimeError( + f"native filename input failed within 5 seconds (Win32 {error_code}); controls={controls}" + ) + if not user32.PostMessageW(dialog, 0x0111, 1, 0): # WM_COMMAND / IDOK + raise RuntimeError("the native package picker rejected its Open command") + wait_until(lambda: not user32.IsWindow(dialog), 10, "the native package picker to close") + + +def finish_onboarding(page: Page) -> None: + done = page.get_by_role("button", name="Done", exact=True) + if done.count() > 0 and done.first.is_visible(): + done.first.click() + done.first.wait_for(state="detached", timeout=15_000) + + +def open_plugin_settings(page: Page) -> None: + category = page.locator("[data-settings-category='declarative-plugins']") + if category.count() == 0 or not category.first.is_visible(): + page.keyboard.press("Control+Comma") + category.first.wait_for(state="visible", timeout=15_000) + category.first.click() + page.locator("[data-settings-content='declarative-plugins']").wait_for( + state="visible", timeout=15_000 + ) + + +def close_settings(page: Page) -> None: + content = page.locator("[data-settings-content='declarative-plugins']") + if content.count() and content.first.is_visible(): + page.get_by_role("button", name="Close", exact=True).last.click() + content.first.wait_for(state="hidden", timeout=15_000) + + +def plugin_row(page: Page): + return page.locator("[data-testid='declarative-plugin-list'] > li").filter(has_text=DISPLAY_NAME) + + +def wait_catalog(page: Page, version: str | None = None, enabled: bool | None = None): + def predicate(): + catalog = invoke(page, "declarative_plugins_state") + match = next((item for item in catalog if item.get("pluginId") == PLUGIN_ID), None) + if not match: + return None + if version is not None and match.get("version") != version: + return None + if enabled is not None and match.get("enabled") is not enabled: + return None + return match + + return wait_until(predicate, 30, f"catalog state version={version} enabled={enabled}") + + +def import_review(page: Page, app_pid: int, package: Path): + open_plugin_settings(page) + page.get_by_role("button", name="Import package…", exact=True).click() + select_native_file(app_pid, package) + review = page.locator("[data-settings-content='declarative-plugins'] [role='dialog']") + review.wait_for(state="visible", timeout=30_000) + return review + + +def confirm_review(page: Page, review) -> None: + review.get_by_role("button", name="Install", exact=True).click() + review.wait_for(state="detached", timeout=45_000) + + +def cancel_review(page: Page, review) -> None: + review.get_by_role("button", name="Cancel", exact=True).click() + review.wait_for(state="detached", timeout=15_000) + + +def open_fixture_file(page: Page, file_name: str = "acceptance.litheaccept") -> None: + close_settings(page) + existing = page.get_by_role("tab", name=re.compile(rf"^{re.escape(file_name)}")) + if existing.count() > 0: + existing.first.click(button="middle") + existing.first.wait_for(state="detached", timeout=15000) + entry = page.get_by_text(file_name, exact=True) + entry.first.wait_for(state="visible", timeout=30_000) + entry.first.dblclick() + + +def evidence_files(state_root: Path, prefix: str) -> list[Path]: + if not state_root.exists(): + return [] + return sorted(state_root.rglob(f"{prefix}-*.json")) + + +def fixture_command_matches(command: str, state_root: Path) -> bool: + # Use Windows' own quoting rules; substring checks would also authorize + # an unrelated file whose name merely contains the fixture/state path. + shell32 = ctypes.windll.shell32 + shell32.CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)] + shell32.CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) + kernel32.LocalFree.argtypes = [wintypes.HLOCAL] + kernel32.LocalFree.restype = wintypes.HLOCAL + count = ctypes.c_int() + arguments = shell32.CommandLineToArgvW(command, ctypes.byref(count)) + if not arguments: + return False + try: + values = [arguments[index] for index in range(count.value)] + if not any(Path(value).name.lower() == "fake-lsp-server.mjs" for value in values[1:]): + return False + index = values.index("--state-dir") + candidate = Path(values[index + 1]).resolve() + root = state_root.resolve() + return candidate == root or root in candidate.parents + except (ValueError, IndexError, OSError): + return False + finally: + kernel32.LocalFree(arguments) + + +def live_fixture_pids(state_root: Path) -> set[int]: + pids = set() + for evidence in evidence_files(state_root, "server-active") + evidence_files( + state_root, "child-active" + ): + with contextlib.suppress(Exception): + pid = int(json.loads(evidence.read_text(encoding="utf-8"))["pid"]) + known_fixture = fixture_processes.get(pid) + if known_fixture is not None and known_fixture.running(): + pids.add(pid) + continue + if not process_exists(pid): + continue + record = next((item for item in process_tree(pid) if int(item["ProcessId"]) == pid), None) + if record is None or not fixture_command_matches(record.get("CommandLine") or "", state_root): + unverified_fixture_pids.add(pid) + continue + owner = remember_owned_process(pid, record) + if owner is not None and owner.running(): + fixture_processes[pid] = owner + pids.add(pid) + else: + unverified_fixture_pids.add(pid) + return pids + + +def wait_for_server_count(state_root: Path, count: int) -> set[int]: + return wait_until( + lambda: (pids if len(pids := live_fixture_pids(state_root)) >= count else None), + 45, + f"{count} controlled language-server process tree(s)", + ) + + +def wait_pids_dead(pids: set[int], description: str) -> None: + wait_until( + lambda: all(not fixture_processes[pid].running() for pid in pids if pid in fixture_processes), + 30, + description, + ) + + +def installation_record(app_local_data: Path) -> dict: + path = app_local_data / "plugins" / PLUGIN_ID / "installation.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def active_fixture_marker(app_local_data: Path, version: str) -> str: + return ( + app_local_data / "plugins" / PLUGIN_ID / version / "fixture-version.txt" + ).read_text(encoding="utf-8").strip() + + +def newest_snapshot(app_local_data: Path) -> Path: + root = app_local_data / "plugin-temp" + snapshots = [path for path in root.glob("lithe-plugin-disk-*") if path.is_dir()] + if not snapshots: + raise RuntimeError("prepared install did not create a private snapshot") + return max(snapshots, key=lambda path: path.stat().st_mtime_ns) + + +def capture_session_failure(session: AppSession | None, artifact_root: Path) -> None: + if session is None or session.page is None: + return + with contextlib.suppress(Exception): + (artifact_root / "screenshots").mkdir(parents=True, exist_ok=True) + session.page.screenshot(path=str(artifact_root / "screenshots" / "failure.png"), timeout=5000) + with contextlib.suppress(Exception): + write_json(artifact_root / "page-failure.json", { + "url": session.page.url, + "text": session.page.locator("body").inner_text(timeout=5000), + }) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--executable", required=True, type=Path) + parser.add_argument("--run-image", required=True, type=Path) + parser.add_argument("--artifact-root", required=True, type=Path) + parser.add_argument("--report", required=True, type=Path) + parser.add_argument("--app-local-data", required=True, type=Path) + parser.add_argument("--app-roaming-data", required=True, type=Path) + parser.add_argument("--webview-data", required=True, type=Path) + parser.add_argument("--package-v1-a", required=True, type=Path) + parser.add_argument("--package-v1-b", required=True, type=Path) + parser.add_argument("--package-v2", required=True, type=Path) + parser.add_argument("--workspace-one", required=True, type=Path) + parser.add_argument("--workspace-two", required=True, type=Path) + args = parser.parse_args() + + artifact_root = args.artifact_root.resolve() + artifact_root.mkdir(parents=True, exist_ok=True) + reporter = Reporter(artifact_root / "progress.json") + reporter.document_pending( + "windows-arm64 native acceptance", + "No Windows arm64 native execution environment is available; x64 compilation or emulation is not acceptance evidence.", + ) + reporter.document_pending( + "macOS native acceptance", + "This driver is intentionally Windows/WebView2-only; macOS requires its own product acceptance lane.", + ) + reporter.document_pending( + "roadmap M2 marketplace package acceptance", + "The public indexes contain no downloadable Windows package, so a real marketplace install cannot yet be claimed.", + ) + reporter.document_pending( + "roadmap M4 build-system provider acceptance", + "The Windows host does not yet advertise buildSystems/buildProviders; this lane must remain pending instead of treating API rejection as a product failure.", + ) + immutable_before = hash_tree(args.run_image.resolve()) + write_json(artifact_root / "resource-hashes-before.json", immutable_before) + session: AppSession | None = None + reporter.capture_failure = lambda: capture_session_failure(session, artifact_root) + all_owned_fixture_pids: set[int] = set() + cleanup_evidence = [] + failure = None + + local_parent = Path(os.environ["LOCALAPPDATA"]) + roaming_parent = Path(os.environ["APPDATA"]) + try: + with reporter.step("isolated application identity and writable roots"): + assert args.app_local_data.resolve() == (local_parent / ACCEPTANCE_IDENTIFIER).resolve() + assert args.app_roaming_data.resolve() == (roaming_parent / ACCEPTANCE_IDENTIFIER).resolve() + safe_remove_owned_directory(args.app_local_data, local_parent, ACCEPTANCE_IDENTIFIER) + safe_remove_owned_directory(args.app_roaming_data, roaming_parent, ACCEPTANCE_IDENTIFIER) + args.app_roaming_data.mkdir(parents=True) + write_json(args.app_roaming_data / "settings.json", {"displayLanguage": "en-US"}) + safe_remove_owned_directory( + args.webview_data, artifact_root.parent / "runtime", "webview2" + ) + args.webview_data.mkdir(parents=True) + assert "host-acceptance" in args.executable.name.lower() + + with sync_playwright() as playwright: + with reporter.step("cancelled review discards its private snapshot"): + session = AppSession( + playwright, + args.executable.resolve(), + args.workspace_one.resolve(), + args.webview_data.resolve(), + artifact_root / "logs", + 1, + ) + page = session.start() + finish_onboarding(page) + review = import_review(page, session.process.pid, args.package_v1_a.resolve()) + assert "Unverified publisher" in review.inner_text() + cancel_review(page, review) + wait_until( + lambda: not list((args.app_local_data / "plugin-temp").glob("lithe-plugin-disk-*")), + 10, + "cancelled review snapshot cleanup", + ) + + with reporter.step("review exposes permissions and the complete program declaration"): + review = import_review(page, session.process.pid, args.package_v1_a.resolve()) + text = review.inner_text() + assert "languageServer.launch" in text + disclosure = review.get_by_role("button", name=re.compile(r"Program declaration \(\d+\)")) + disclosure.click() + declaration = review.locator("pre").inner_text() + assert '"kind": "runtimeLanguageServer"' in declaration + assert '"tool": "node"' in declaration + assert '"entry": "server/fake-lsp-server.mjs"' in declaration + (artifact_root / "screenshots").mkdir(parents=True, exist_ok=True) + page.screenshot(path=str(artifact_root / "screenshots" / "install-review.png")) + confirm_review(page, review) + installed = wait_catalog(page, "1.0.0", True) + write_json(artifact_root / "installed-catalog.json", installed) + assert installed["status"] == "accepted", installed.get("rejection") + record = installation_record(args.app_local_data) + assert record["schemaVersion"] == 2 + assert record["origin"] == "community" + assert [json.loads(program) for program in record["disk"]["approvedPrograms"]] == json.loads(declaration) + with args.package_v1_a.open("rb") as archive: + assert record["disk"]["archiveSha256"] == hashlib.file_digest(archive, "sha256").hexdigest() + + state_root = args.app_local_data / "plugin-state" / PLUGIN_ID + with reporter.step("installed plugin survives application restart"): + cleanup_evidence.append(session.stop(require_graceful=True)) + session = None + session = AppSession( + playwright, + args.executable.resolve(), + args.workspace_one.resolve(), + args.webview_data.resolve(), + artifact_root / "logs", + 2, + ) + page = session.start() + finish_onboarding(page) + open_plugin_settings(page) + wait_catalog(page, "1.0.0", True) + assert plugin_row(page).count() == 1 + + lsp_started = False + try: + with reporter.step("opening a declared language starts its server and child"): + open_fixture_file(page) + live = wait_for_server_count(state_root, 2) + all_owned_fixture_pids.update(live) + lsp_started = True + except Exception as error: + # Preserve the product failure while exercising independent install transactions. + failure = error + diagnostic = {} + for command in ("declarative_plugins_language_owner", "declarative_plugins_language_launch"): + try: + arguments = {"filePath": str(args.workspace_one.resolve() / "acceptance.litheaccept")} + if command.endswith("_launch"): + arguments["workspacePath"] = str(args.workspace_one.resolve()) + diagnostic[command] = invoke(page, command, arguments) + except Exception as diagnostic_error: + diagnostic[command] = {"error": str(diagnostic_error)} + write_json(artifact_root / "language-launch-diagnostic.json", diagnostic) + + if lsp_started: + with reporter.step("application exit revokes the language-server process tree"): + first_live = live_fixture_pids(state_root) + cleanup_evidence.append(session.stop(require_graceful=True)) + session = None + wait_pids_dead(first_live, "language-server and child cleanup on application exit") + session = AppSession( + playwright, args.executable.resolve(), args.workspace_one.resolve(), + args.webview_data.resolve(), artifact_root / "logs", 3, + ) + page = session.start() + finish_onboarding(page) + open_fixture_file(page) + all_owned_fixture_pids.update(wait_for_server_count(state_root, 2)) + with reporter.step("two product windows revoke both owned process trees on disable"): + known_pages = { + candidate + for context in session.browser.contexts + for candidate in context.pages + } + invoke(page, "create_app_window", {"request": { + "type": "path", "path": str(args.workspace_two.resolve()), "isDirectory": True, + }}) + + def second_product_page(): + for context in session.browser.contexts: + for candidate in context.pages: + if candidate not in known_pages: + try: + if candidate.evaluate("() => Boolean(window.__TAURI_INTERNALS__?.invoke)"): + return candidate + except Exception: + continue + return None + + second = wait_until(second_product_page, 30, "the second product window") + second.wait_for_load_state("domcontentloaded", timeout=15_000) + second.wait_for_function("() => Boolean(window.__TAURI_INTERNALS__?.invoke)", timeout=15_000) + finish_onboarding(second) + open_fixture_file(second) + live = wait_for_server_count(state_root, 4) + all_owned_fixture_pids.update(live) + open_plugin_settings(page) + row = plugin_row(page) + row.get_by_role("switch").click() + wait_catalog(page, "1.0.0", False) + wait_pids_dead(live, "both windows' language-server process trees after disable") + row.get_by_role("switch").click() + wait_catalog(page, "1.0.0", True) + else: + reporter.document_pending( + "application exit revokes the language-server process tree", + "The declared language did not activate its server; no process cleanup success is claimed.", + "plugin-host-acceptance / native-windows-x64", + ) + reporter.document_pending( + "two product windows revoke both owned process trees on disable", + "The declared language did not activate its server; process revocation was not exercised.", + "plugin-host-acceptance / native-windows-x64", + ) + + with reporter.step("approval-bound bytes reject post-review tampering"): + review = import_review(page, session.process.pid, args.package_v2.resolve()) + snapshot = newest_snapshot(args.app_local_data) + server = snapshot / "server" / "fake-lsp-server.mjs" + server.write_text(server.read_text(encoding="utf-8") + "\n// tampered after review\n", encoding="utf-8") + review.get_by_role("button", name="Install", exact=True).click() + review.wait_for(state="detached", timeout=45_000) + alert = page.locator("[data-settings-content='declarative-plugins'] [role='alert']") + alert.wait_for(state="visible", timeout=15_000) + assert re.search(r"(changed|digest|review|bytes)", alert.inner_text(), re.IGNORECASE) + wait_catalog(page, "1.0.0", True) + assert active_fixture_marker(args.app_local_data, "1.0.0") == "1.0.0-a" + wait_until( + lambda: not list((args.app_local_data / "plugin-temp").glob("lithe-plugin-disk-*")), + 10, + "rejected tampered snapshot cleanup", + ) + + with reporter.step("same-version replacement, update, and rollback preserve provenance"): + review = import_review(page, session.process.pid, args.package_v1_b.resolve()) + confirm_review(page, review) + wait_catalog(page, "1.0.0", True) + assert active_fixture_marker(args.app_local_data, "1.0.0") == "1.0.0-b" + record = installation_record(args.app_local_data) + assert record["version"] == "1.0.0" + with args.package_v1_b.open("rb") as archive: + replacement_digest = hashlib.file_digest(archive, "sha256").hexdigest() + assert record["disk"]["archiveSha256"] == replacement_digest + + review = import_review(page, session.process.pid, args.package_v2.resolve()) + confirm_review(page, review) + wait_catalog(page, "1.1.0", True) + record = installation_record(args.app_local_data) + assert record["version"] == "1.1.0" + assert record["previousVersion"] == "1.0.0" + assert record["previousDisk"]["archiveSha256"] == replacement_digest + assert active_fixture_marker(args.app_local_data, "1.1.0") == "1.1.0" + + plugin_row(page).get_by_role("button", name="Roll back", exact=True).click() + wait_catalog(page, "1.0.0", True) + assert active_fixture_marker(args.app_local_data, "1.0.0") == "1.0.0-b" + assert installation_record(args.app_local_data)["disk"]["archiveSha256"] == replacement_digest + + with reporter.step("uninstall removes package and state"): + live = set() + if lsp_started: + open_fixture_file(page) + live = wait_for_server_count(state_root, 2) + all_owned_fixture_pids.update(live) + open_plugin_settings(page) + plugin_row(page).get_by_role("button", name="Uninstall", exact=True).click() + wait_until( + lambda: plugin_row(page).count() == 0, + 45, + "the plugin row to disappear after uninstall", + ) + if live: + with reporter.step("uninstall revokes the language-server process tree"): + wait_pids_dead(live, "language-server process cleanup on uninstall") + else: + reporter.document_pending( + "uninstall revokes the language-server process tree", + "The declared language did not activate its server; no process cleanup success is claimed.", + "plugin-host-acceptance / native-windows-x64", + ) + assert not (args.app_local_data / "plugins" / PLUGIN_ID).exists() + assert not (args.app_local_data / "plugin-state" / PLUGIN_ID).exists() + + with reporter.step("owned application and helper processes cleanly terminate"): + cleanup_evidence.append(session.stop(require_graceful=True)) + session = None + wait_pids_dead(all_owned_fixture_pids, "all recorded fixture processes") + write_json(artifact_root / "process-cleanup.json", cleanup_evidence) + + except Exception as error: # noqa: BLE001 - final cleanup and report must always run. + failure = failure or error + finally: + cleanup_errors = [] + if session is not None: + try: + cleanup_evidence.append(session.stop(require_graceful=False)) + except Exception as error: + cleanup_errors.append(str(error)) + owned_pids = all_owned_fixture_pids | live_fixture_pids( + args.app_local_data / "plugin-state" / PLUGIN_ID + ) + for pid in owned_pids: + try: + if owned_process_running(pid): + terminate_process_tree(pid) + except Exception as error: + cleanup_errors.append(str(error)) + write_json(artifact_root / "process-ownership-skipped.json", { + "unverifiedFixturePids": sorted(unverified_fixture_pids), + "action": "No termination authorized without a retained matching process handle.", + }) + for process in owned_processes.values(): + process.close() + owned_processes.clear() + fixture_processes.clear() + try: + if args.app_local_data.exists(): + evidence_copy = artifact_root / "isolated-app-data" + if evidence_copy.exists(): + shutil.rmtree(evidence_copy) + evidence_copy.mkdir(parents=True) + for name in ("logs", "plugins", "plugin-state", "plugin-temp", "settings.json"): + source = args.app_local_data / name + if source.is_dir(): + shutil.copytree(source, evidence_copy / name) + elif source.is_file(): + shutil.copy2(source, evidence_copy / name) + except Exception as error: + cleanup_errors.append(f"Could not preserve isolated app-data evidence: {error}") + for path, parent in ( + (args.app_local_data, local_parent), + (args.app_roaming_data, roaming_parent), + ): + try: + safe_remove_owned_directory(path, parent, ACCEPTANCE_IDENTIFIER) + except Exception as error: + cleanup_errors.append(str(error)) + try: + safe_remove_owned_directory( + args.webview_data, artifact_root.parent / "runtime", "webview2" + ) + except Exception as error: + cleanup_errors.append(str(error)) + write_json(artifact_root / "process-cleanup.json", cleanup_evidence) + if cleanup_errors: + reporter.tests.append( + { + "name": "failure-path cleanup", + "suite": "plugin-host-acceptance / native-windows-x64", + "status": "failed", + "durationMs": 0, + "details": "\n".join(cleanup_errors), + "maxMs": 30000, + } + ) + failure = failure or RuntimeError("; ".join(cleanup_errors)) + try: + with reporter.step("release executable and packaged resources remain byte-identical"): + immutable_after = hash_tree(args.run_image.resolve()) + write_json(artifact_root / "resource-hashes-after.json", immutable_after) + added = sorted(set(immutable_after) - set(immutable_before)) + removed = sorted(set(immutable_before) - set(immutable_after)) + changed = sorted( + path + for path in set(immutable_before) & set(immutable_after) + if immutable_before[path] != immutable_after[path] + ) + write_json( + artifact_root / "resource-hash-diff.json", + {"added": added, "removed": removed, "changed": changed}, + ) + assert not added and not removed and not changed + except Exception as error: + failure = failure or error + planned_cases = ( + "cancelled review discards its private snapshot", + "review exposes permissions and the complete program declaration", + "installed plugin survives application restart", + "opening a declared language starts its server and child", + "application exit revokes the language-server process tree", + "two product windows revoke both owned process trees on disable", + "approval-bound bytes reject post-review tampering", + "same-version replacement, update, and rollback preserve provenance", + "uninstall removes package and state", + "uninstall revokes the language-server process tree", + "owned application and helper processes cleanly terminate", + ) + completed_names = {test["name"] for test in reporter.tests} + for name in planned_cases: + if name not in completed_names: + reporter.tests.append( + { + "name": name, + "suite": "plugin-host-acceptance / native-windows-x64", + "status": "skipped", + "durationMs": 0, + "details": "An earlier acceptance step failed; this case was not executed.", + "maxMs": 120000, + } + ) + write_json( + args.report, + { + "runner": "acceptance", + "warnMs": 30_000, + "maxMs": 120_000, + "tests": reporter.tests, + }, + ) + + if failure is not None: + print(f"Windows plugin host acceptance failed: {failure}", file=sys.stderr) + return 1 + print("Windows plugin host acceptance passed with isolated application identity.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/acceptance_driver_test.py b/scripts/plugin-host-acceptance/acceptance_driver_test.py new file mode 100644 index 0000000000..6eb9322dba --- /dev/null +++ b/scripts/plugin-host-acceptance/acceptance_driver_test.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Bounded unit checks for the native acceptance driver's process owner.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from acceptance_driver import OwnedProcess, fixture_command_matches, process_exists, run_bounded_process + + +def run_case(name, action, tests): + started = time.monotonic() + try: + action() + status = "passed" + details = "" + except Exception as error: # noqa: BLE001 - the report must retain any failure. + status = "failed" + details = f"{type(error).__name__}: {error}" + tests.append( + { + "name": name, + "suite": "plugin-host-acceptance / driver", + "status": status, + "durationMs": round((time.monotonic() - started) * 1000, 3), + "details": details, + "maxMs": 10_000, + } + ) + if status != "passed": + raise AssertionError(details) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--report", required=True, type=Path) + args = parser.parse_args() + tests = [] + + def completes(): + result = run_bounded_process( + [sys.executable, "-c", "print('bounded-ok')"], timeout_seconds=5 + ) + assert result.returncode == 0 + assert result.stdout.strip() == "bounded-ok" + + def timeout_cleans_tree(): + args.report.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix="lithe-host-driver-test-", dir=args.report.parent + ) as directory: + pid_file = Path(directory) / "child.pid" + script = ( + "import pathlib,subprocess,sys,time; " + "p=subprocess.Popen([sys.executable,'-c','import time; time.sleep(60)']); " + f"pathlib.Path({str(pid_file)!r}).write_text(str(p.pid)); " + "time.sleep(60)" + ) + try: + run_bounded_process([sys.executable, "-c", script], timeout_seconds=1) + except TimeoutError: + pass + else: + raise AssertionError("bounded process did not time out") + deadline = time.monotonic() + 5 + while not pid_file.exists() and time.monotonic() < deadline: + time.sleep(0.02) + assert pid_file.exists(), "fixture child PID was not recorded" + child_pid = int(pid_file.read_text(encoding="utf-8")) + deadline = time.monotonic() + 5 + while process_exists(child_pid) and time.monotonic() < deadline: + time.sleep(0.05) + assert not process_exists(child_pid), f"timed-out child {child_pid} survived cleanup" + + def retained_handle_ignores_changed_pid(): + first = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + second = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + owner = None + try: + owner = OwnedProcess.capture(first.pid) + assert owner is not None + # Model PID reuse without depending on the OS actually recycling it. + owner.pid = second.pid + owner.terminate() + first.wait(timeout=3) + assert second.poll() is None, "termination followed a recycled PID instead of the retained handle" + finally: + if owner is not None: + owner.close() + for process in (first, second): + if process.poll() is None: + process.kill() + process.wait(timeout=3) + + def mismatched_identity_is_not_owned(): + process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + original = rejected = None + try: + original = OwnedProcess.capture(process.pid) + assert original is not None + rejected = OwnedProcess.capture(process.pid, { + "CreationTime": original.creation_time - 100, + "ExecutablePath": original.executable, + }) + assert rejected is None + rejected = OwnedProcess.capture(process.pid, { + "CreationTime": original.creation_time, + "ExecutablePath": original.executable + ".unrelated", + }) + assert rejected is None + assert process.poll() is None + finally: + if original is not None: + original.close() + if rejected is not None: + rejected.close() + if process.poll() is None: + process.kill() + process.wait(timeout=3) + + def fixture_arguments_are_exact(): + root = Path(tempfile.gettempdir()) / "lithe fixture state" + valid = subprocess.list2cmdline(["node.exe", "C:/fixture/fake-lsp-server.mjs", "--state-dir", str(root)]) + assert fixture_command_matches(valid, root) + assert not fixture_command_matches(valid.replace("fake-lsp-server.mjs", "other-fake-lsp-server.mjs"), root) + sibling = subprocess.list2cmdline(["node.exe", "C:/fixture/fake-lsp-server.mjs", "--state-dir", str(root) + "-unrelated"]) + assert not fixture_command_matches(sibling, root) + + try: + run_case("bounded process captures a successful child", completes, tests) + run_case("timeout terminates the owned process tree", timeout_cleans_tree, tests) + run_case("termination targets a retained handle despite a changed PID", retained_handle_ignores_changed_pid, tests) + run_case("creation-time and executable mismatches reject ownership", mismatched_identity_is_not_owned, tests) + run_case("fixture arguments reject substring lookalikes", fixture_arguments_are_exact, tests) + finally: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text( + json.dumps( + { + "runner": "acceptance", + "warnMs": 2_000, + "maxMs": 10_000, + "tests": tests, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/fake-lsp-server.mjs b/scripts/plugin-host-acceptance/fake-lsp-server.mjs new file mode 100644 index 0000000000..3c05c515de --- /dev/null +++ b/scripts/plugin-host-acceptance/fake-lsp-server.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import path from "node:path"; + +const argv = process.argv.slice(2); + +function argument(name, fallback = "") { + const index = argv.indexOf(name); + return index >= 0 && index + 1 < argv.length ? argv[index + 1] : fallback; +} + +const stateDirectory = path.resolve(argument("--state-dir", process.cwd())); +const fixtureVersion = argument("--fixture-version", "unknown"); +const childMode = argv.includes("--child"); +mkdirSync(stateDirectory, { recursive: true }); + +function writeEvidence(kind, extra = {}) { + const evidence = { + kind, + pid: process.pid, + parentPid: process.ppid, + fixtureVersion, + timestamp: new Date().toISOString(), + ...extra, + }; + writeFileSync( + path.join(stateDirectory, `${kind}-${fixtureVersion}-${process.pid}.json`), + `${JSON.stringify(evidence, null, 2)}\n`, + "utf8", + ); +} + +function logEvent(event, extra = {}) { + appendFileSync( + path.join(stateDirectory, "events.jsonl"), + `${JSON.stringify({ event, pid: process.pid, fixtureVersion, ...extra })}\n`, + "utf8", + ); +} + +if (childMode) { + writeEvidence("child-active"); + logEvent("child-started"); + const interval = setInterval(() => {}, 60_000); + const stop = (signal) => { + clearInterval(interval); + writeEvidence("child-stopped", { signal }); + process.exit(0); + }; + process.on("SIGINT", () => stop("SIGINT")); + process.on("SIGTERM", () => stop("SIGTERM")); +} else { + writeEvidence("server-active"); + logEvent("server-started", { argv }); + + const child = spawn( + process.execPath, + [ + fileURLToPath(import.meta.url), + "--child", + "--state-dir", + stateDirectory, + "--fixture-version", + fixtureVersion, + ], + { stdio: "ignore", windowsHide: true }, + ); + child.unref(); + writeEvidence("child-owned", { childPid: child.pid }); + + let buffer = Buffer.alloc(0); + let shutdownRequested = false; + + function send(message) { + const body = Buffer.from(JSON.stringify(message), "utf8"); + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); + process.stdout.write(body); + } + + function handle(message) { + logEvent("message", { method: message.method ?? null, id: message.id ?? null }); + if (message.method === "initialize") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { + capabilities: { + textDocumentSync: 1, + hoverProvider: false, + }, + serverInfo: { name: "lithe-plugin-host-acceptance", version: fixtureVersion }, + }, + }); + return; + } + if (message.method === "shutdown") { + shutdownRequested = true; + send({ jsonrpc: "2.0", id: message.id, result: null }); + return; + } + if (message.method === "exit") { + finish(shutdownRequested ? 0 : 1, "lsp-exit"); + return; + } + if (message.id !== undefined) { + send({ jsonrpc: "2.0", id: message.id, result: null }); + } + } + + function drain() { + while (true) { + const separator = buffer.indexOf("\r\n\r\n"); + if (separator < 0) return; + const header = buffer.subarray(0, separator).toString("ascii"); + const match = /(?:^|\r\n)Content-Length:\s*(\d+)/i.exec(header); + if (!match) finish(2, "malformed-header"); + const length = Number.parseInt(match[1], 10); + const frameEnd = separator + 4 + length; + if (buffer.length < frameEnd) return; + const body = buffer.subarray(separator + 4, frameEnd).toString("utf8"); + buffer = buffer.subarray(frameEnd); + handle(JSON.parse(body)); + } + } + + let finished = false; + function finish(code, reason) { + if (finished) return; + finished = true; + writeEvidence("server-stopped", { code, reason, childPid: child.pid }); + logEvent("server-stopped", { code, reason, childPid: child.pid }); + process.exit(code); + } + + process.stdin.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + drain(); + }); + process.stdin.on("end", () => finish(0, "stdin-end")); + process.stdin.on("error", (error) => finish(3, `stdin-error:${error.message}`)); + process.on("SIGINT", () => finish(0, "SIGINT")); + process.on("SIGTERM", () => finish(0, "SIGTERM")); +} diff --git a/scripts/plugin-host-acceptance/generate_packages.py b/scripts/plugin-host-acceptance/generate_packages.py new file mode 100644 index 0000000000..541df1bdd5 --- /dev/null +++ b/scripts/plugin-host-acceptance/generate_packages.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Generate deterministic community disk packages for native host acceptance.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import zipfile +from pathlib import Path + + +PLUGIN_ID = "io.github.lithe-acceptance.host" +DISPLAY_NAME = "Lithe Host Acceptance" + + +def manifest(version: str, fixture_version: str) -> dict: + return { + "schemaVersion": 2, + "id": PLUGIN_ID, + "displayName": DISPLAY_NAME, + "version": version, + "pluginAPIVersion": 2, + "vendor": { + "id": "io.github.lithe-acceptance", + "displayName": "Lithe acceptance fixture", + "signatureRequirement": "none", + }, + "permissions": ["languageServer.launch"], + "activationEvents": ["onLanguage:litheaccept"], + "languages": [ + { + "id": "litheaccept", + "displayName": "Lithe Acceptance", + "fileExtensions": ["litheaccept"], + "projectFileNames": ["acceptance.litheaccept"], + } + ], + "languageServers": [ + { + "languageID": "litheaccept", + "languageIdentifier": "litheaccept", + "launch": { + "kind": "runtime", + "tool": "node", + "entry": "server/fake-lsp-server.mjs", + }, + "arguments": [ + "--stdio", + "--state-dir", + "${workspaceStateDir}", + "--fixture-version", + fixture_version, + ], + } + ], + } + + +def write_package( + output_root: Path, + server_source: Path, + package_name: str, + version: str, + fixture_version: str, +) -> Path: + package_root = output_root / package_name + if package_root.exists(): + shutil.rmtree(package_root) + (package_root / "server").mkdir(parents=True) + (package_root / "plugin.json").write_text( + json.dumps(manifest(version, fixture_version), indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + (package_root / "fixture-version.txt").write_text(f"{fixture_version}\n", encoding="utf-8") + shutil.copy2(server_source, package_root / "server" / server_source.name) + + archive = output_root / f"{package_name}.zip" + if archive.exists(): + archive.unlink() + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as package_zip: + for source in sorted(path for path in package_root.rglob("*") if path.is_file()): + relative = source.relative_to(package_root).as_posix() + info = zipfile.ZipInfo(relative, date_time=(2026, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + package_zip.writestr(info, source.read_bytes()) + return archive + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--server", required=True, type=Path) + args = parser.parse_args() + output = args.output.resolve() + server = args.server.resolve() + if not server.is_file(): + raise SystemExit(f"Fake language server is missing: {server}") + output.mkdir(parents=True, exist_ok=True) + packages = { + "v1_a": write_package(output, server, "host-acceptance-1.0.0-a", "1.0.0", "1.0.0-a"), + "v1_b": write_package(output, server, "host-acceptance-1.0.0-b", "1.0.0", "1.0.0-b"), + "v2": write_package(output, server, "host-acceptance-1.1.0", "1.1.0", "1.1.0"), + } + print(json.dumps({key: str(value) for key, value in packages.items()}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 5f0747da99..6fc5c56d45 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -83,6 +83,9 @@ async function readRegistry() { // both original state and migration snapshots and must not gain a validator. // Shared i18n source and generated tables take this same refusal route: // rebuild from the target checkout, never copy another worktree's translations. + // Windows source lists, raw index caches, digest-bound download files and + // per-workspace build options use plugin-marketplace-state's refusal route. + // Core validation or a matching archive hash never transfers user ownership. const excludedResources = registry.excludedResources ?? []; if (!Array.isArray(excludedResources)) throw new Error("Invalid excluded worktree resources"); for (const resource of excludedResources) { diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 0bdabe9e56..55236381d2 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -299,6 +299,20 @@ try { await assert.rejects(fs.access(missingTarget), { code: "ENOENT" }); } }); + await test("Windows plugin indexes downloads and build options are isolated runtime state", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "plugin-marketplace-state"); + assert.equal(resource.reusable, false); + for (const location of [ + "/plugin-marketplace/sources.json", + "/plugin-marketplace/..json", + "/marketplace-download--.zip", + "/plugin-build-options/.json", + ]) assert.ok(resource.locations.includes(location), location); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /plugin-marketplace-state is isolated.*cannot be reused/); + }); await testFailedBackupPreservesDestination(); await fs.mkdir(path.join(sourceRoot, "third_party", "jdtls"), { recursive: true }); await fs.writeFile( diff --git a/scripts/test-windows-declarative-plugin-host.ps1 b/scripts/test-windows-declarative-plugin-host.ps1 new file mode 100644 index 0000000000..14cbacdb1d --- /dev/null +++ b/scripts/test-windows-declarative-plugin-host.ps1 @@ -0,0 +1,483 @@ +[CmdletBinding()] +param( + [ValidateSet("x86_64-pc-windows-msvc")] + [string]$RustTarget = "x86_64-pc-windows-msvc", + [switch]$SkipBuild, + [switch]$ResumeBuild, + [string]$ResourceSource = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +if (-not $IsWindows) { throw "The declarative plugin host acceptance requires Windows." } +if ($SkipBuild -and $ResumeBuild) { throw "Choose either -SkipBuild or -ResumeBuild." } + +$root = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot)) +$artifactParent = [IO.Path]::GetFullPath((Join-Path $root ".artifacts")) +$artifactRoot = [IO.Path]::GetFullPath((Join-Path $artifactParent "plugin-host-acceptance")) +$stabilityRoot = [IO.Path]::GetFullPath((Join-Path $artifactParent "test-stability")) +$fixtureRoot = Join-Path $root "scripts/plugin-host-acceptance" +$profileRoot = [IO.Path]::GetFullPath( + (Join-Path $root "windows/tauri/src-tauri/target/$RustTarget/release") +) +$sourceExecutable = Join-Path $profileRoot "lithe-windows.exe" +$identityMarker = Join-Path $artifactRoot "build-identity.json" +$acceptanceIdentifier = "app.lithe.windows.host-acceptance" + +function Assert-OwnedPath { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Parent) + $resolved = [IO.Path]::GetFullPath($Path) + $resolvedParent = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\' + if (-not $resolved.StartsWith($resolvedParent, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to manage a path outside $Parent`: $resolved" + } +} + +function Reset-OwnedDirectory { + param([Parameter(Mandatory)][string]$Path) + Assert-OwnedPath -Path $Path -Parent $artifactParent + if (Test-Path -LiteralPath $Path) { + Remove-Item -LiteralPath $Path -Recurse -Force + } + New-Item -ItemType Directory -Path $Path -Force | Out-Null +} + +function Invoke-NativeChecked { + param( + [Parameter(Mandatory)][string]$Executable, + [Parameter(Mandatory)][string[]]$Arguments, + [Parameter(Mandatory)][string]$FailureMessage + ) + & $Executable @Arguments + if ($LASTEXITCODE -ne 0) { throw "$FailureMessage (exit $LASTEXITCODE)" } +} + +function Invoke-BoundedDriver { + param([string[]]$Arguments, [string]$LogPath, [int]$TimeoutSeconds = 900) + $startInfo = [Diagnostics.ProcessStartInfo]::new((Get-Command python).Source) + $startInfo.WorkingDirectory = $root + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } + $driver = [Diagnostics.Process]::new() + $driver.StartInfo = $startInfo + $started = $false + try { + $started = $driver.Start() + $stdout = $driver.StandardOutput.ReadToEndAsync() + $stderr = $driver.StandardError.ReadToEndAsync() + if (-not $driver.WaitForExit($TimeoutSeconds * 1000)) { + throw "The native acceptance driver exceeded its $TimeoutSeconds second deadline." + } + if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout, $stderr), 5000)) { + throw "The native acceptance driver output did not close within 5 seconds." + } + $output = $stdout.GetAwaiter().GetResult() + $stderr.GetAwaiter().GetResult() + $output | Set-Content -LiteralPath $LogPath -Encoding utf8NoBOM + Write-Host $output + return $driver.ExitCode + } finally { + try { + if ($started -and -not $driver.HasExited) { + $driver.Kill($true) + if (-not $driver.WaitForExit(5000)) { throw "The acceptance driver survived bounded cleanup." } + } + } finally { + $driver.Dispose() + } + } +} + +function Write-NormalizedReport { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Status, + [Parameter(Mandatory)][string]$Details + ) + @{ + runner = "acceptance" + warnMs = 1000 + maxMs = 15000 + tests = @(@{ + name = $Name + suite = "plugin-host-acceptance / source-guard" + status = $Status + durationMs = 0 + maxMs = 15000 + details = $Details + }) + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $Path -Encoding utf8NoBOM +} + +function Stop-OwnedAcceptanceProcesses { + param([Parameter(Mandatory)][string]$StagedExecutable) + $executablePath = [IO.Path]::GetFullPath($StagedExecutable) + $snapshot = @(Get-CimInstance Win32_Process) + $survivors = @($snapshot | Where-Object { + ($_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $executablePath) -or + ($_.CommandLine -and + $_.CommandLine.Contains("fake-lsp-server.mjs", [StringComparison]::OrdinalIgnoreCase) -and + $_.CommandLine.Contains($acceptanceIdentifier, [StringComparison]::OrdinalIgnoreCase)) + }) + # Retain kernel handles before terminating anything. A PID from a CIM + # snapshot is never sufficient authority to kill a later process. + $owners = [Collections.Generic.List[Diagnostics.Process]]::new() + $pending = [Collections.Generic.Queue[object]]::new() + foreach ($record in $survivors) { $pending.Enqueue($record) } + $seen = @{} + try { + while ($pending.Count -gt 0) { + $record = $pending.Dequeue() + $processId = [int]$record.ProcessId + if ($seen.ContainsKey($processId)) { continue } + $seen[$processId] = $true + $owner = $null + $retained = $false + try { + $owner = [Diagnostics.Process]::GetProcessById($processId) + $null = $owner.Handle + if ($owner.HasExited) { continue } + $created = $record.CreationDate.ToUniversalTime().ToFileTimeUtc() + $actualCreated = $owner.StartTime.ToUniversalTime().ToFileTimeUtc() + if ([Math]::Floor($created / 10) -ne [Math]::Floor($actualCreated / 10)) { continue } + if (-not $record.ExecutablePath -or + [IO.Path]::GetFullPath($owner.MainModule.FileName) -ne [IO.Path]::GetFullPath($record.ExecutablePath)) { continue } + $owners.Add($owner) + $retained = $true + foreach ($child in $snapshot) { + if ($child.ParentProcessId -eq $processId -and $child.CreationDate -ge $record.CreationDate) { + $pending.Enqueue($child) + } + } + } catch [ArgumentException] { + # The original process exited before its handle was captured. + } catch [InvalidOperationException] { + # The retained process exited during inspection. + } finally { + if ($null -ne $owner -and -not $retained) { $owner.Dispose() } + } + } + foreach ($owner in $owners) { + if (-not $owner.HasExited) { $owner.Kill() } + } + $deadline = [Diagnostics.Stopwatch]::StartNew() + foreach ($owner in $owners) { + $remainingMs = [Math]::Max(0, 5000 - [int]$deadline.ElapsedMilliseconds) + if (-not $owner.WaitForExit($remainingMs)) { + throw "Acceptance-owned process survived cleanup: $($owner.Id)" + } + } + } finally { + foreach ($owner in $owners) { $owner.Dispose() } + } + return $survivors +} + +New-Item -ItemType Directory -Path $artifactParent -Force | Out-Null +New-Item -ItemType Directory -Path $stabilityRoot -Force | Out-Null +if (-not $SkipBuild -and -not $ResumeBuild) { + Reset-OwnedDirectory -Path $artifactRoot +} else { + New-Item -ItemType Directory -Path $artifactRoot -Force | Out-Null + if ($SkipBuild -and -not (Test-Path -LiteralPath $identityMarker -PathType Leaf)) { + throw "-SkipBuild requires the prior isolated build identity marker: $identityMarker" + } + if ($ResumeBuild -and -not (Test-Path -LiteralPath (Join-Path $artifactRoot "release-build.log") -PathType Leaf)) { + throw "-ResumeBuild requires evidence that this lane already attempted its formal Release build." + } + if ($SkipBuild) { + $priorIdentity = Get-Content -LiteralPath $identityMarker -Raw | ConvertFrom-Json + if ($priorIdentity.identifier -ne $acceptanceIdentifier -or + $priorIdentity.executableSha256 -ne (Get-FileHash -Algorithm SHA256 -LiteralPath $sourceExecutable).Hash.ToLowerInvariant()) { + throw "The prior executable no longer matches its isolated application identity marker." + } + } +} + +$runImage = Join-Path $artifactRoot "run-image" +$packages = Join-Path $artifactRoot "packages" +$workspaces = Join-Path $artifactRoot "workspaces" +$runtimeRoot = Join-Path $artifactRoot "runtime" +$evidenceRoot = Join-Path $artifactRoot "evidence" +foreach ($directory in @($runImage, $packages, $workspaces, $runtimeRoot, $evidenceRoot)) { + Reset-OwnedDirectory -Path $directory +} + +$customConfig = Join-Path $artifactRoot "tauri.host-acceptance.conf.json" +@{ + '$schema' = "https://schema.tauri.app/config/2" + productName = "Lithe Host Acceptance" + identifier = $acceptanceIdentifier +} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $customConfig -Encoding utf8NoBOM + +Invoke-NativeChecked -Executable "node" -Arguments @("--check", (Join-Path $fixtureRoot "fake-lsp-server.mjs")) ` + -FailureMessage "The controlled language-server fixture has invalid JavaScript" +Invoke-NativeChecked -Executable "python" -Arguments @( + "-B", + "-c", + "import ast,pathlib; [ast.parse(pathlib.Path(p).read_text(encoding='utf-8')) for p in __import__('sys').argv[1:]]", + (Join-Path $fixtureRoot "acceptance_driver.py"), + (Join-Path $fixtureRoot "acceptance_driver_test.py"), + (Join-Path $fixtureRoot "generate_packages.py") +) -FailureMessage "A plugin host acceptance Python source file has invalid syntax" + +& (Join-Path $root ".agents/skills/write-stable-tests/scripts/verify-test-stability.ps1") -Platform windows +if ($LASTEXITCODE -ne 0) { throw "The Windows stable-test source guard failed." } +& (Join-Path $root "scripts/verify-windows-boundaries.ps1") +if ($LASTEXITCODE -ne 0) { throw "The Windows architecture boundary verification failed." } + +$driverUnitReport = Join-Path $stabilityRoot "plugin-host-acceptance-driver.json" +Invoke-NativeChecked -Executable "python" -Arguments @( + "-B", + (Join-Path $fixtureRoot "acceptance_driver_test.py"), + "--report", $driverUnitReport +) -FailureMessage "The acceptance process-owner unit checks failed" + +$zsh = Get-Command zsh -ErrorAction SilentlyContinue +$immutabilityReport = Join-Path $stabilityRoot "plugin-host-acceptance-source-guard.json" +if ($null -eq $zsh) { + Write-NormalizedReport -Path $immutabilityReport ` + -Name "original runtime bundle immutability source guard" ` + -Status "skipped" ` + -Details "zsh is unavailable on this Windows host; scripts/verify-runtime-bundle-immutability.sh remains pending and no substitute scan is labeled runtime acceptance." +} else { + $timer = [Diagnostics.Stopwatch]::StartNew() + & $zsh.Source (Join-Path $root "scripts/verify-runtime-bundle-immutability.sh") + if ($LASTEXITCODE -ne 0) { throw "The original runtime bundle immutability source guard failed." } + Write-NormalizedReport -Path $immutabilityReport ` + -Name "original runtime bundle immutability source guard" ` + -Status "passed" ` + -Details "scripts/verify-runtime-bundle-immutability.sh completed through zsh in $($timer.ElapsedMilliseconds) ms." +} + +if (-not $SkipBuild) { + $heavyProcesses = @(Get-CimInstance Win32_Process | Where-Object { + $_.Name -in @("cargo.exe", "rustc.exe") + }) + if ($heavyProcesses.Count -gt 0) { + throw "Refusing to overlap the formal Release build with existing Cargo/Rust processes: $($heavyProcesses.ProcessId -join ', ')" + } + + if (-not [string]::IsNullOrWhiteSpace($ResourceSource)) { + $resourceSourcePath = [IO.Path]::GetFullPath($ResourceSource) + if (-not (Test-Path -LiteralPath $resourceSourcePath -PathType Container)) { + throw "Resource source does not exist: $resourceSourcePath" + } + Invoke-NativeChecked -Executable "node" -Arguments @( + (Join-Path $root "scripts/reuse-worktree-resources.mjs"), + "--source", $resourceSourcePath + ) -FailureMessage "Worktree resource reuse failed" + } + + $previousCargoJobs = $env:CARGO_BUILD_JOBS + $previousToolchain = $env:RUSTUP_TOOLCHAIN + $previousConpty = $env:LITHE_REQUIRE_CONPTY + $transcriptStarted = $false + try { + $env:CARGO_BUILD_JOBS = "2" + $env:RUSTUP_TOOLCHAIN = "stable-$RustTarget" + $env:LITHE_REQUIRE_CONPTY = "1" + $buildLogName = if ($ResumeBuild) { "release-resume.log" } else { "release-build.log" } + Start-Transcript -LiteralPath (Join-Path $artifactRoot $buildLogName) -Force | Out-Null + $transcriptStarted = $true + + # This is deliberately the only formal Release build invocation in the lane. + if (-not $ResumeBuild) { + & (Join-Path $root "scripts/build-windows.ps1") -Configuration Release -RustTarget $RustTarget + if ($LASTEXITCODE -ne 0) { throw "The formal Windows Release build failed." } + } else { + Push-Location (Join-Path $root "windows/tauri") + try { + Invoke-NativeChecked -Executable "bun" -Arguments @("run", "typecheck") ` + -FailureMessage "The resumed Windows frontend type check failed" + } finally { + Pop-Location + } + } + + $tauriArguments = @( + "tauri", "build", "--no-bundle", + "--config", "src-tauri/tauri.windows.conf.json", + "--config", "src-tauri/tauri.jdtls.conf.json", + "--config", $customConfig, + "--target", $RustTarget + ) + & (Join-Path $root "scripts/invoke-windows-tauri-build.ps1") ` + -TauriArguments $tauriArguments ` + -FailureMessage "The incremental host-acceptance identity link failed" + if ($LASTEXITCODE -ne 0) { throw "The incremental host-acceptance identity link failed." } + + if (-not (Test-Path -LiteralPath $sourceExecutable -PathType Leaf)) { + throw "The isolated Windows executable was not produced: $sourceExecutable" + } + @{ + identifier = $acceptanceIdentifier + rustTarget = $RustTarget + sourceExecutable = $sourceExecutable + gitRevision = (& git -C $root rev-parse HEAD).Trim() + configSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $customConfig).Hash.ToLowerInvariant() + executableSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $sourceExecutable).Hash.ToLowerInvariant() + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $identityMarker -Encoding utf8NoBOM + } finally { + if ($transcriptStarted) { Stop-Transcript | Out-Null } + $env:CARGO_BUILD_JOBS = $previousCargoJobs + $env:RUSTUP_TOOLCHAIN = $previousToolchain + $env:LITHE_REQUIRE_CONPTY = $previousConpty + } +} + +if (-not (Test-Path -LiteralPath $sourceExecutable -PathType Leaf)) { + throw "The custom-identity Release executable is missing: $sourceExecutable" +} + +$stagedExecutable = Join-Path $runImage "lithe-host-acceptance.exe" +Copy-Item -LiteralPath $sourceExecutable -Destination $stagedExecutable -Force +$resourceNames = @( + "extensions", + "OfficialPlugins", + "LanguageServers", + "helpers", + "conpty.dll", + "OpenConsole.exe", + "ConPTY-LICENSE.txt" +) +foreach ($resourceName in $resourceNames) { + $source = Join-Path $profileRoot $resourceName + if (Test-Path -LiteralPath $source) { + Copy-Item -LiteralPath $source -Destination (Join-Path $runImage $resourceName) -Recurse -Force + } +} + +# A resumed build may have been interrupted before build-windows.ps1 staged +# resources. Populate the run image from the same immutable build inputs. +$fallbackResources = [ordered]@{ + "extensions/bundled" = (Join-Path $root "windows/tauri/src/extensions/bundled") + "OfficialPlugins/PhpSupportContract" = (Join-Path $root "Plugins/win/Official/PhpSupportContract") + "LanguageServers/jdtls" = (Join-Path $artifactParent "jdtls") + "LanguageServers/jdk" = (Join-Path $artifactParent "jdk") + "helpers" = (Join-Path $root "windows/tauri/src-tauri/helpers") + "conpty.dll" = (Join-Path $artifactParent "conpty/conpty.dll") + "OpenConsole.exe" = (Join-Path $artifactParent "conpty/OpenConsole.exe") + "ConPTY-LICENSE.txt" = (Join-Path $artifactParent "conpty/ConPTY-LICENSE.txt") +} +foreach ($resource in $fallbackResources.GetEnumerator()) { + $destination = Join-Path $runImage $resource.Key + if (-not (Test-Path -LiteralPath $destination) -and (Test-Path -LiteralPath $resource.Value)) { + New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null + Copy-Item -LiteralPath $resource.Value -Destination $destination -Recurse -Force + } +} +foreach ($required in @( + $stagedExecutable, + (Join-Path $runImage "extensions/bundled"), + (Join-Path $runImage "OfficialPlugins"), + (Join-Path $runImage "LanguageServers/jdtls"), + (Join-Path $runImage "LanguageServers/jdk/bin/java.exe"), + (Join-Path $runImage "conpty.dll"), + (Join-Path $runImage "OpenConsole.exe") +)) { + if (-not (Test-Path -LiteralPath $required)) { throw "The staged run image is incomplete: $required" } +} + +Invoke-NativeChecked -Executable "python" -Arguments @( + "-B", + (Join-Path $fixtureRoot "generate_packages.py"), + "--output", $packages, + "--server", (Join-Path $fixtureRoot "fake-lsp-server.mjs") +) -FailureMessage "The deterministic plugin packages could not be generated" + +$workspaceOne = Join-Path $workspaces "one" +$workspaceTwo = Join-Path $workspaces "two" +New-Item -ItemType Directory -Path $workspaceOne, $workspaceTwo -Force | Out-Null +Set-Content -LiteralPath (Join-Path $workspaceOne "acceptance.litheaccept") -Value "workspace one" -Encoding utf8NoBOM +Set-Content -LiteralPath (Join-Path $workspaceTwo "acceptance.litheaccept") -Value "workspace two" -Encoding utf8NoBOM + +$appLocalData = Join-Path ([Environment]::GetFolderPath("LocalApplicationData")) $acceptanceIdentifier +$appRoamingData = Join-Path ([Environment]::GetFolderPath("ApplicationData")) $acceptanceIdentifier +$webviewData = Join-Path $runtimeRoot "webview2" +$acceptanceReport = Join-Path $stabilityRoot "plugin-host-acceptance.json" +$driverLog = Join-Path $artifactRoot "acceptance-driver.log" + +@{ + schemaVersion = 1 + identifier = $acceptanceIdentifier + gitRevision = (& git -C $root rev-parse HEAD).Trim() + rustTarget = $RustTarget + osArchitecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() + executable = $stagedExecutable + executableSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $stagedExecutable).Hash.ToLowerInvariant() + runImage = $runImage + resourceNames = @($resourceNames | Where-Object { Test-Path -LiteralPath (Join-Path $runImage $_) }) + appLocalData = $appLocalData + appRoamingData = $appRoamingData + webviewData = $webviewData +} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $artifactRoot "metadata.json") -Encoding utf8NoBOM + +$driverExit = 1 +$cleanupFailure = $null +try { + $driverExit = Invoke-BoundedDriver -LogPath $driverLog -Arguments @( + "-B", (Join-Path $fixtureRoot "acceptance_driver.py"), + "--executable", $stagedExecutable, + "--run-image", $runImage, + "--artifact-root", $evidenceRoot, + "--report", $acceptanceReport, + "--app-local-data", $appLocalData, + "--app-roaming-data", $appRoamingData, + "--webview-data", $webviewData, + "--package-v1-a", (Join-Path $packages "host-acceptance-1.0.0-a.zip"), + "--package-v1-b", (Join-Path $packages "host-acceptance-1.0.0-b.zip"), + "--package-v2", (Join-Path $packages "host-acceptance-1.1.0.zip"), + "--workspace-one", $workspaceOne, + "--workspace-two", $workspaceTwo + ) +} catch { + $driverFailure = $_.ToString() + Write-NormalizedReport -Path $acceptanceReport -Name "native acceptance driver deadline or startup" ` + -Status "failed" -Details $driverFailure + throw +} finally { + try { + $forced = @(Stop-OwnedAcceptanceProcesses -StagedExecutable $stagedExecutable) + if ($forced.Count -gt 0) { + $cleanupFailure = "The wrapper had to terminate acceptance-owned process IDs: $($forced.ProcessId -join ', ')" + } + } catch { + $cleanupFailure = $_.ToString() + } + foreach ($isolatedData in @($appLocalData, $appRoamingData)) { + $resolvedData = [IO.Path]::GetFullPath($isolatedData) + if ([IO.Path]::GetFileName($resolvedData) -ne $acceptanceIdentifier) { + $cleanupFailure = "Refusing cleanup of a non-acceptance data directory: $resolvedData" + continue + } + if (Test-Path -LiteralPath $resolvedData) { + try { + $dataEvidence = Join-Path $evidenceRoot ("wrapper-cleanup-" + [IO.Path]::GetFileName([IO.Path]::GetDirectoryName($resolvedData))) + New-Item -ItemType Directory -Path $dataEvidence -Force | Out-Null + foreach ($evidenceName in @("logs", "plugins", "plugin-state", "plugin-temp", "settings.json")) { + $evidenceSource = Join-Path $resolvedData $evidenceName + if (Test-Path -LiteralPath $evidenceSource) { + Copy-Item -LiteralPath $evidenceSource -Destination (Join-Path $dataEvidence $evidenceName) -Recurse -Force + } + } + Remove-Item -LiteralPath $resolvedData -Recurse -Force + } catch { + $cleanupFailure = $_.ToString() + } + } + } + & node (Join-Path $root ".agents/skills/write-stable-tests/scripts/generate-test-report.mjs") ` + --input-dir $stabilityRoot + if ($LASTEXITCODE -ne 0 -and $null -eq $cleanupFailure) { + $cleanupFailure = "Test stability report generation failed." + } +} + +if ($null -ne $cleanupFailure) { throw $cleanupFailure } +if ($driverExit -ne 0) { throw "The native Windows declarative plugin host acceptance failed (exit $driverExit)." } +Write-Output "Windows declarative plugin host acceptance passed; reports are in $stabilityRoot." diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 89ea9c4c36..1b5deb5330 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -157,7 +157,11 @@ "/Lithe/PluginMarketplace//index.json", "/Lithe/PluginMarketplace/sources.json", "/lithe-plugin-transfer-", - "/lithe-plugin-download-" + "/lithe-plugin-download-", + "/plugin-marketplace/sources.json", + "/plugin-marketplace/..json", + "/marketplace-download--.zip", + "/plugin-build-options/.json" ], "identity": "Fetched marketplace indexes, the user's plugin repository list, and downloaded or extracted plugin archives are per-user runtime state verified by Rust Core index validation and the index SHA-256; the official index ships read-only from Git-tracked Plugins/marketplace/index.json and has no worktree build stamp", "reason": "Marketplace caches, repository lists and plugin downloads belong to the running app and its user, never to a worktree build. ZIP preflight budgets include unique implicit parent directories and cumulative normalized UTF-8 path bytes before extraction; passing these checks does not create a reusable build identity. Do not copy, symlink or bundle them; each run fetches and verifies its own copy. The bundled index is copied from Git at packaging time only." diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 7d8d4260dd..d207ee781b 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -8864,7 +8864,101 @@ "settings.declarativePlugins.settingEffect.reload": "takes effect when the plugin model reloads", "settings.declarativePlugins.settingEffect.nextRun": "takes effect on the next run", "native.databaseIsNowAPluginInstallItFromTheMarketplace": "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it.", - "native.openMarketplace": "Open Marketplace" + "native.openMarketplace": "Open Marketplace", + "settings.declarativePlugins.sources.title": "Plugin sources", + "settings.declarativePlugins.sources.description": "Sources of the declarative plugin marketplace. Preset sources are rebuilt by the host; the official source can be disabled but not removed.", + "settings.declarativePlugins.sources.add": "Add source", + "settings.declarativePlugins.sources.addPlaceholder": "https://github.com/owner/repo or an index URL", + "settings.declarativePlugins.sources.empty": "No third-party sources added.", + "settings.declarativePlugins.sources.official": "Official", + "settings.declarativePlugins.sources.thirdParty": "Third-party", + "settings.declarativePlugins.sources.remove": "Remove", + "settings.declarativePlugins.sources.toggle": "Enable or disable source {name}", + "settings.declarativePlugins.sources.errorAlreadyAdded": "That source has already been added.", + "settings.declarativePlugins.sources.errorOfficialCannotBeRemoved": "The official source cannot be removed.", + "settings.declarativePlugins.sources.errorNotFound": "That source no longer exists.", + "settings.declarativePlugins.sources.errorInvalidInput": "Enter an https URL of a plugin index or a GitHub repository.", + "settings.declarativePlugins.sources.errorLoad": "The source list could not be loaded.", + "settings.declarativePlugins.sources.errorGeneric": "The source could not be updated. Try again.", + "settings.declarativePlugins.sources.corruptNotice": "The saved source list was unreadable. The original file was preserved next to the store and the list was rebuilt from the presets.", + "settings.declarativePlugins.sources.retry": "Retry", + "buildSystems.close": "Close", + "buildSystems.refresh": "Reload", + "buildSystems.manifestRejected": "{plugin}: {message}", + "buildSystems.shadowed": "{plugin} is shadowed by {by}", + "buildSystems.detectFailed": "Build detection failed: {message}", + "buildSystems.loading": "Detecting build systems…", + "buildSystems.emptyState": "No {name} project detected in this workspace.", + "buildSystems.instanceSelector": "Instance", + "buildSystems.modelError": "Model error: {message}", + "buildSystems.staleBanner": "The project model is out of date. Reload to pick up the changes.", + "buildSystems.optionsTitle": "Options", + "buildSystems.projectsTitle": "Projects", + "buildSystems.fromModel": "from model", + "buildSystems.reset": "Reset", + "buildSystems.resetOption": "Reset {name}", + "buildSystems.railLabel": "{name} build window", + "settings.declarativePluginDetails.noSelection": "Select a plugin to view its details.", + "settings.declarativePluginDetails.loading": "Loading plugin details…", + "settings.declarativePluginDetails.retry": "Retry", + "settings.declarativePluginDetails.tabs": "Plugin detail sections", + "settings.declarativePluginDetails.tab.overview": "Overview", + "settings.declarativePluginDetails.tab.features": "Features", + "settings.declarativePluginDetails.tab.settings": "Settings", + "settings.declarativePluginDetails.tab.permissions": "Permissions & Programs", + "settings.declarativePluginDetails.tab.versions": "Versions & Source", + "settings.declarativePluginDetails.notice.diskCommunity": "Installed from disk. The publisher and repository are not authenticated; Lithe verifies the exact bytes you approved.", + "settings.declarativePluginDetails.notice.community": "Community package. Its source is shown for context and does not by itself prove an official publisher.", + "settings.declarativePluginDetails.action.enable": "Enable", + "settings.declarativePluginDetails.action.disable": "Disable", + "settings.declarativePluginDetails.action.rollback": "Roll back…", + "settings.declarativePluginDetails.action.uninstall": "Uninstall", + "settings.declarativePluginDetails.field.status": "Status", + "settings.declarativePluginDetails.field.provides": "Provides", + "settings.declarativePluginDetails.field.source": "Source", + "settings.declarativePluginDetails.field.settings": "Settings", + "settings.declarativePluginDetails.field.update": "Updates", + "settings.declarativePluginDetails.field.pluginId": "Plugin ID", + "settings.declarativePluginDetails.field.version": "Version", + "settings.declarativePluginDetails.field.verification": "Verification", + "settings.declarativePluginDetails.field.repository": "Repository", + "settings.declarativePluginDetails.field.archiveDigest": "Archive SHA-256", + "settings.declarativePluginDetails.field.contentDigest": "Content SHA-256", + "settings.declarativePluginDetails.status.quarantined": "Quarantined", + "settings.declarativePluginDetails.status.enabled": "Enabled", + "settings.declarativePluginDetails.status.disabled": "Disabled", + "settings.declarativePluginDetails.empty.features": "This plugin declares no features.", + "settings.declarativePluginDetails.empty.settings": "This plugin declares no settings.", + "settings.declarativePluginDetails.empty.permissions": "This plugin requests no permissions.", + "settings.declarativePluginDetails.empty.programs": "This plugin starts no programs.", + "settings.declarativePluginDetails.settingsDisabled": "Enable this accepted plugin to edit its settings.", + "settings.declarativePluginDetails.availability.available": "Available on Windows", + "settings.declarativePluginDetails.availability.declaredOnly": "Declared, not currently hosted on Windows", + "settings.declarativePluginDetails.permissions": "Permissions", + "settings.declarativePluginDetails.programs": "Programs this plugin can start", + "settings.declarativePluginDetails.program": "Complete program declaration {index}", + "settings.declarativePluginDetails.permission.backend.launch": "Start the plugin's background service", + "settings.declarativePluginDetails.permission.credentials.store": "Store credentials through Lithe", + "settings.declarativePluginDetails.permission.languageServer.launch": "Start a language server", + "settings.declarativePluginDetails.permission.process.build": "Run build tools", + "settings.declarativePluginDetails.permission.process.run": "Run project programs", + "settings.declarativePluginDetails.permission.process.test": "Run project tests", + "settings.declarativePluginDetails.permission.process.vcs": "Run version-control tools", + "settings.declarativePluginDetails.permission.project.generate": "Generate project files", + "settings.declarativePluginDetails.permission.ui.contribute": "Add commands and tool windows", + "settings.declarativePluginDetails.permission.ui.web": "Show plugin-provided web content", + "settings.declarativePluginDetails.currentVersion": "Current version", + "settings.declarativePluginDetails.previousVersion": "Previous version", + "settings.declarativePluginDetails.update.unknown": "Marketplace update data is not connected yet.", + "settings.declarativePluginDetails.source.bundled": "Bundled with Lithe", + "settings.declarativePluginDetails.source.marketplace": "Marketplace", + "settings.declarativePluginDetails.source.disk": "Local disk package", + "settings.declarativePluginDetails.source.unknown": "Unknown", + "settings.declarativePluginDetails.verification.publisherSignature": "Publisher signature verified", + "settings.declarativePluginDetails.verification.hostSeal": "Verified as part of the Lithe installation", + "settings.declarativePluginDetails.verification.approvedDigest": "Matches the bytes approved during installation", + "settings.declarativePluginDetails.verification.rejected": "Verification failed", + "settings.declarativePluginDetails.verification.unknown": "Not verified yet" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 0f523922b7..4c30255b89 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -8864,7 +8864,101 @@ "settings.declarativePlugins.settingEffect.reload": "插件模型重新加载后生效", "settings.declarativePlugins.settingEffect.nextRun": "下次运行生效", "native.databaseIsNowAPluginInstallItFromTheMarketplace": "数据库已改为插件。从插件市场安装后,已保存的连接和密码会自动迁入。", - "native.openMarketplace": "打开插件市场" + "native.openMarketplace": "打开插件市场", + "settings.declarativePlugins.sources.title": "插件源", + "settings.declarativePlugins.sources.description": "声明式插件市场的来源。预置来源由宿主重建;官方源可停用、不可删除。", + "settings.declarativePlugins.sources.add": "添加源", + "settings.declarativePlugins.sources.addPlaceholder": "https://github.com/owner/repo 或索引 URL", + "settings.declarativePlugins.sources.empty": "没有已添加的第三方源。", + "settings.declarativePlugins.sources.official": "官方", + "settings.declarativePlugins.sources.thirdParty": "第三方", + "settings.declarativePlugins.sources.remove": "删除", + "settings.declarativePlugins.sources.toggle": "启用或停用源 {name}", + "settings.declarativePlugins.sources.errorAlreadyAdded": "该来源已添加。", + "settings.declarativePlugins.sources.errorOfficialCannotBeRemoved": "官方来源不能删除。", + "settings.declarativePlugins.sources.errorNotFound": "该来源已不存在。", + "settings.declarativePlugins.sources.errorInvalidInput": "请输入插件索引或 GitHub 仓库的 https 地址。", + "settings.declarativePlugins.sources.errorLoad": "来源列表加载失败。", + "settings.declarativePlugins.sources.errorGeneric": "来源操作失败,请重试。", + "settings.declarativePlugins.sources.corruptNotice": "已保存的来源列表无法读取。原文件已保留在存储目录旁,列表已按预置来源重建。", + "settings.declarativePlugins.sources.retry": "重试", + "buildSystems.close": "关闭", + "buildSystems.refresh": "重新加载", + "buildSystems.manifestRejected": "{plugin}:{message}", + "buildSystems.shadowed": "{plugin} 的构建系统由 {by} 接管", + "buildSystems.detectFailed": "构建识别失败:{message}", + "buildSystems.loading": "正在识别构建系统…", + "buildSystems.emptyState": "此工作区未检测到 {name} 工程。", + "buildSystems.instanceSelector": "实例根", + "buildSystems.modelError": "模型错误:{message}", + "buildSystems.staleBanner": "工程模型已过期,重新加载以应用更改。", + "buildSystems.optionsTitle": "选项", + "buildSystems.projectsTitle": "工程", + "buildSystems.fromModel": "来自模型", + "buildSystems.reset": "重置", + "buildSystems.resetOption": "重置 {name}", + "buildSystems.railLabel": "{name} 构建窗口", + "settings.declarativePluginDetails.noSelection": "选择一个插件以查看详情。", + "settings.declarativePluginDetails.loading": "正在加载插件详情…", + "settings.declarativePluginDetails.retry": "重试", + "settings.declarativePluginDetails.tabs": "插件详情分区", + "settings.declarativePluginDetails.tab.overview": "概览", + "settings.declarativePluginDetails.tab.features": "功能", + "settings.declarativePluginDetails.tab.settings": "设置", + "settings.declarativePluginDetails.tab.permissions": "权限与程序", + "settings.declarativePluginDetails.tab.versions": "版本与来源", + "settings.declarativePluginDetails.notice.diskCommunity": "此插件从磁盘安装,发布者与仓库未经认证;Lithe 会校验你批准过的确切字节。", + "settings.declarativePluginDetails.notice.community": "这是社区插件。来源信息仅供参考,不能单独证明官方发布者身份。", + "settings.declarativePluginDetails.action.enable": "启用", + "settings.declarativePluginDetails.action.disable": "停用", + "settings.declarativePluginDetails.action.rollback": "回滚…", + "settings.declarativePluginDetails.action.uninstall": "卸载", + "settings.declarativePluginDetails.field.status": "状态", + "settings.declarativePluginDetails.field.provides": "提供", + "settings.declarativePluginDetails.field.source": "来源", + "settings.declarativePluginDetails.field.settings": "设置", + "settings.declarativePluginDetails.field.update": "更新", + "settings.declarativePluginDetails.field.pluginId": "插件 ID", + "settings.declarativePluginDetails.field.version": "版本", + "settings.declarativePluginDetails.field.verification": "验证", + "settings.declarativePluginDetails.field.repository": "仓库", + "settings.declarativePluginDetails.field.archiveDigest": "归档 SHA-256", + "settings.declarativePluginDetails.field.contentDigest": "内容 SHA-256", + "settings.declarativePluginDetails.status.quarantined": "已隔离", + "settings.declarativePluginDetails.status.enabled": "已启用", + "settings.declarativePluginDetails.status.disabled": "已停用", + "settings.declarativePluginDetails.empty.features": "此插件未声明任何功能。", + "settings.declarativePluginDetails.empty.settings": "此插件未声明任何设置。", + "settings.declarativePluginDetails.empty.permissions": "此插件不请求任何权限。", + "settings.declarativePluginDetails.empty.programs": "此插件不会启动任何程序。", + "settings.declarativePluginDetails.settingsDisabled": "启用这个已接受的插件后才能编辑其设置。", + "settings.declarativePluginDetails.availability.available": "Windows 当前可用", + "settings.declarativePluginDetails.availability.declaredOnly": "已声明,但 Windows 当前尚未托管", + "settings.declarativePluginDetails.permissions": "权限", + "settings.declarativePluginDetails.programs": "此插件可以启动的程序", + "settings.declarativePluginDetails.program": "完整程序声明 {index}", + "settings.declarativePluginDetails.permission.backend.launch": "启动插件后台服务", + "settings.declarativePluginDetails.permission.credentials.store": "通过 Lithe 存储凭据", + "settings.declarativePluginDetails.permission.languageServer.launch": "启动语言服务器", + "settings.declarativePluginDetails.permission.process.build": "运行构建工具", + "settings.declarativePluginDetails.permission.process.run": "运行项目程序", + "settings.declarativePluginDetails.permission.process.test": "运行项目测试", + "settings.declarativePluginDetails.permission.process.vcs": "运行版本控制工具", + "settings.declarativePluginDetails.permission.project.generate": "生成项目文件", + "settings.declarativePluginDetails.permission.ui.contribute": "添加命令与工具窗口", + "settings.declarativePluginDetails.permission.ui.web": "显示插件提供的网页内容", + "settings.declarativePluginDetails.currentVersion": "当前版本", + "settings.declarativePluginDetails.previousVersion": "上一版本", + "settings.declarativePluginDetails.update.unknown": "插件市场更新数据尚未接入。", + "settings.declarativePluginDetails.source.bundled": "随 Lithe 内置", + "settings.declarativePluginDetails.source.marketplace": "插件市场", + "settings.declarativePluginDetails.source.disk": "本地磁盘包", + "settings.declarativePluginDetails.source.unknown": "未知", + "settings.declarativePluginDetails.verification.publisherSignature": "已验证发布者签名", + "settings.declarativePluginDetails.verification.hostSeal": "已验证为 Lithe 安装的一部分", + "settings.declarativePluginDetails.verification.approvedDigest": "与安装时批准的字节一致", + "settings.declarativePluginDetails.verification.rejected": "验证失败", + "settings.declarativePluginDetails.verification.unknown": "尚未验证" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index e64e202c04..a1a0bd3682 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -82758,6 +82758,693 @@ "key": "Open Marketplace", "arguments": [] } + }, + { + "id": "buildSystems.close", + "description": "Windows declarative build UI: buildSystems.close", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.detectFailed", + "description": "Windows declarative build UI: buildSystems.detectFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "buildSystems.emptyState", + "description": "Windows declarative build UI: buildSystems.emptyState", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "buildSystems.fromModel", + "description": "Windows declarative build UI: buildSystems.fromModel", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.instanceSelector", + "description": "Windows declarative build UI: buildSystems.instanceSelector", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.loading", + "description": "Windows declarative build UI: buildSystems.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.manifestRejected", + "description": "Windows declarative build UI: buildSystems.manifestRejected", + "platforms": [ + "windows" + ], + "parameters": { + "plugin": "string", + "message": "string" + } + }, + { + "id": "buildSystems.modelError", + "description": "Windows declarative build UI: buildSystems.modelError", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "buildSystems.optionsTitle", + "description": "Windows declarative build UI: buildSystems.optionsTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.projectsTitle", + "description": "Windows declarative build UI: buildSystems.projectsTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.railLabel", + "description": "Windows declarative build UI: buildSystems.railLabel", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "buildSystems.refresh", + "description": "Reload or retry Windows declarative build detection and models", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.reset", + "description": "Windows declarative build UI: buildSystems.reset", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.resetOption", + "description": "Windows declarative build UI: buildSystems.resetOption", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "buildSystems.shadowed", + "description": "Windows declarative build UI: buildSystems.shadowed", + "platforms": [ + "windows" + ], + "parameters": { + "plugin": "string", + "by": "string" + } + }, + { + "id": "buildSystems.staleBanner", + "description": "Windows declarative build UI: buildSystems.staleBanner", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.action.disable", + "description": "Windows plugin details action: disable", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.action.enable", + "description": "Windows plugin details action: enable", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.action.rollback", + "description": "Windows plugin details action: rollback", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.action.uninstall", + "description": "Windows plugin details action: uninstall", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.availability.available", + "description": "Windows plugin details: host capability available", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.availability.declaredOnly", + "description": "Windows plugin details: declaration not hosted", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.currentVersion", + "description": "Windows plugin details: current version", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.empty.features", + "description": "Windows plugin details: no features", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.empty.permissions", + "description": "Windows plugin details: no permissions", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.empty.programs", + "description": "Windows plugin details: no programs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.empty.settings", + "description": "Windows plugin details: no settings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.archiveDigest", + "description": "Windows plugin details field: archive SHA-256", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.contentDigest", + "description": "Windows plugin details field: content SHA-256", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.pluginId", + "description": "Windows plugin details field: plugin ID", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.provides", + "description": "Windows plugin details field: provides", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.repository", + "description": "Windows plugin details field: repository", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.settings", + "description": "Windows plugin details field: settings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.source", + "description": "Windows plugin details field: source", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.status", + "description": "Windows plugin details field: status", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.update", + "description": "Windows plugin details field: update", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.verification", + "description": "Windows plugin details field: verification", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.field.version", + "description": "Windows plugin details field: version", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.loading", + "description": "Windows plugin details: loading", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.noSelection", + "description": "Windows plugin details: no selection", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.notice.community", + "description": "Windows plugin details: community trust notice", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.notice.diskCommunity", + "description": "Windows plugin details: disk community trust notice", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.backend.launch", + "description": "Windows plugin permission: launch backend", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.credentials.store", + "description": "Windows plugin permission: store credentials", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.languageServer.launch", + "description": "Windows plugin permission: launch language server", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.process.build", + "description": "Windows plugin permission: run builds", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.process.run", + "description": "Windows plugin permission: run programs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.process.test", + "description": "Windows plugin permission: run tests", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.process.vcs", + "description": "Windows plugin permission: run VCS", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.project.generate", + "description": "Windows plugin permission: generate projects", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.ui.contribute", + "description": "Windows plugin permission: contribute UI", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permission.ui.web", + "description": "Windows plugin permission: show web content", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.permissions", + "description": "Windows plugin details section: permissions", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.previousVersion", + "description": "Windows plugin details: previous version", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.program", + "description": "Windows plugin details: complete numbered program declaration", + "platforms": [ + "windows" + ], + "parameters": { + "index": "integer" + } + }, + { + "id": "settings.declarativePluginDetails.programs", + "description": "Windows plugin details section: programs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.retry", + "description": "Windows plugin details: retry", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.settingsDisabled", + "description": "Windows plugin details: settings disabled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.source.bundled", + "description": "Windows plugin details source: bundled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.source.disk", + "description": "Windows plugin details source: disk", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.source.marketplace", + "description": "Windows plugin details source: marketplace", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.source.unknown", + "description": "Windows plugin details source: unknown", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.status.disabled", + "description": "Windows plugin details status: disabled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.status.enabled", + "description": "Windows plugin details status: enabled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.status.quarantined", + "description": "Windows plugin details status: quarantined", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.tab.features", + "description": "Windows plugin details tab: features", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.tab.overview", + "description": "Windows plugin details tab: overview", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.tab.permissions", + "description": "Windows plugin details tab: permissions and programs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.tab.settings", + "description": "Windows plugin details tab: settings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.tab.versions", + "description": "Windows plugin details tab: versions and source", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.tabs", + "description": "Windows plugin details: tab list label", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.update.unknown", + "description": "Windows plugin details: update status unavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.verification.approvedDigest", + "description": "Windows plugin details verification: approved digest", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.verification.hostSeal", + "description": "Windows plugin details verification: host seal", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.verification.publisherSignature", + "description": "Windows plugin details verification: publisher signature", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.verification.rejected", + "description": "Windows plugin details verification: rejected", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePluginDetails.verification.unknown", + "description": "Windows plugin details verification: unknown", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.add", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.add", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.addPlaceholder", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.addPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.corruptNotice", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.corruptNotice", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.description", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.description", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.empty", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.errorAlreadyAdded", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.errorAlreadyAdded", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.errorGeneric", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.errorGeneric", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.errorInvalidInput", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.errorInvalidInput", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.errorLoad", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.errorLoad", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.errorNotFound", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.errorNotFound", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.errorOfficialCannotBeRemoved", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.errorOfficialCannotBeRemoved", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.official", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.official", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.remove", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.remove", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.retry", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.thirdParty", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.thirdParty", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.title", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.title", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.sources.toggle", + "description": "Windows declarative marketplace source management: settings.declarativePlugins.sources.toggle", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } } ] } diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index b2bc106b4f..e18d710d71 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -37,11 +37,17 @@ "evidence": [ "rust/lithe-core/src/build", "shared/contracts/plugins/v1/fixtures/build-plans.json", - "shared/contracts/plugins/v1/fixtures/build-provider.plugin.json" + "shared/contracts/plugins/v1/fixtures/build-provider.plugin.json", + "windows/tauri/src-tauri/src/plugin_host/build_registry.rs", + "windows/tauri/src-tauri/src/plugin_host/build.rs", + "windows/tauri/src-tauri/src/plugin_host/build_tests.rs", + "windows/tauri/src/features/build-systems", + "windows/tauri/src-tauri/src/plugin_host/build_resolver.rs", + "windows/tauri/src-tauri/src/plugin_host/build_resolver_tests.rs" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口与 lithe/build/execute 尚未接入,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。" + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口与 lithe/build/execute 尚未接入,Provider 无操作系统级沙盒;Windows 宿主已接入内部静态 build.* 命令,生产构建能力仍关闭。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;Windows M4-A 内部准备(2026-10-25):plugin_host/build.rs + build_registry.rs 经真实 Core 完成 build.detect、build.resolveOptions 与 build.syncFingerprint 的只读接线,静态模型识别、按工作区 + 构建系统共享的选项存储(显式空多选按实例根标记、重置清除全部标记、兄弟实例已选值保留)、指纹过期标记与每窗口会话登记表已落地并附带 fixture 驱动的宿主测试;hostCapabilities 仍不声明 buildSystems/executableResolution,构建插件在 Windows 尚不可安装,工具窗口仅在插件声明 buildSystems 且被启用时注册,当前生产目录恒为空,无用户可见入口;工具解析已有内部实现,command/hostIntegration 模型执行、任务/测试运行、Provider 与网页 build.* 桥留待后续批次,完成后才评估是否开放能力并更新本条状态。;M4-A 审查修复回归:build-systems-store/service 测试覆盖请求开始后禁用再返回、非活动工作区撤销、A→B→A 激活后串行重建会话并编辑/同步;build_tests 覆盖模型加载前后指纹一致性、首个文件事件标记 stale、刷新重建基线;build-system-pane.test.tsx 覆盖契约 singleSelect/allowCustom、文本及路径草稿提交、零实例空态、失败重试与 stale 重载。Windows 仍为内部 partial/pending,未开放构建或可执行文件解析能力。;Windows 工具解析器已完成内部接线:遵循 Core 候选顺序、显式失败不回退、冻结当前目录与环境,并将相对 PATH、环境 home 和宿主 runtime 转为同一次操作内稳定的绝对探测路径;被拒清单按 Core 请求 index 映射插件身份,不按压缩列表序号误报。定向回归与统一宿主计时测试通过。解析会话尚未接入实际构建/测试执行、同意策略、输出与取消协调器;生产仍不声明 buildSystems/executableResolution/buildProviders,保持 partial/pending。" } diff --git a/shared/platform-feature-matrix/features/declarative-plugin-contract.json b/shared/platform-feature-matrix/features/declarative-plugin-contract.json index b5e6707d5b..b715412806 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-contract.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-contract.json @@ -53,12 +53,17 @@ "windows/tauri/src-tauri/src/plugin_host/lifecycle.rs", "windows/tauri/src/features/plugins/declarative-plugin-ui.ts", "windows/tauri/src/features/plugins/declarative-plugin-ui.test.ts", - "windows/tauri/src/platform/lsp-core-adapter.test.ts" + "windows/tauri/src/platform/lsp-core-adapter.test.ts", + "windows/tauri/src/features/editor/lsp/declarative-language-support.ts", + "windows/tauri/src/features/editor/lsp/declarative-language-support.test.ts", + "windows/tauri/src/features/editor/hooks/use-lsp-integration-declarative.test.tsx", + "scripts/test-windows-declarative-plugin-host.ps1", + "scripts/plugin-host-acceptance/acceptance_driver.py" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Extensions", "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:[1,2], serverProcess:1, settings:[1,2], ui:[1,2]},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。 后续安全修复已补插件身份传递、运行中和待启动会话排空、跨窗口包变更确认、完整启动前检查及 ui 1 文本/命令入口;目前 WebView 仍为独立窗口,停靠位置、图标、动作状态及布局持久化未完成。 M1 信任链已接入:windows/tauri/src-tauri/src/plugin_host/signature.rs 与 macOS PluginPackageSignature 字节兼容(canonical payload、UTF-8 base64url token、按原始路径排序、每文件 SHA-256、contentDigest),使用成熟的 ed25519-dalek 校验固定官方公钥(lithe-official-plugins-v2);官方 ID 信任策略由宿主固定(php/java-support=sameTeamAsHost、go-support=publisherPackage),覆盖包自声明,sameTeamAsHost 仅信任 bundled 资源;签名文档存在即必须验证。安装记录升为 schema 2:origin、marketplace/disk provenance(全包内容摘要与批准程序)、当前/上一版本证据成一致事务,版本激活与记录提交同事务、提交失败恢复旧字节与旧记录;schema 1 旧记录不凭 enabled 伪造信任,扫描直接隔离并要求重新安装;bundled 身份仅由可信资源发现器产生,installed 记录声明 bundled(含 previousOrigin)一律隔离,sameTeamAsHost 在扫描、启动与回滚均校验真实资源根包含。prepare 冻结整包摘要与审查程序,confirm 与 staging 对同一审核身份校验,prepare 后替换/新增/删除文件拒绝;磁盘渠道接受已验签 publisherPackage 包(保留真实 disk provenance),sameTeam 磁盘包拒绝。安装确认、staging 发布前、启动扫描、运行/测试计划入口与回滚均校验完整文件集合与摘要(community 与 marketplace origin 都复检已批准摘要);ZIP 大小写冲突条目、reparse point、路径逃逸与签名文档损坏均被拒绝。双端共享公开向量 shared/contracts/plugins/v1/fixtures/signature-vectors.json(RFC 8032 测试密钥锚点,非 ASCII 文件名覆盖),Rust 端经 shared_fixture_verifies_end_to_end 断言;Swift 端 PluginSignatureVectorTests 消费同一文件,macOS 实机运行待 CI。生产私钥不进入源码与测试。 安装中断恢复使用完整版本名备份并复核 durable record 批准摘要,校验插件身份和单段版本路径;真实替换产生的备份清理失败、下一次记录提交失败与越界记录均有 Windows 回归。", - "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移未完成,宿主 UI 仅完成文本和命令入口,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 仍缺停靠、图标和动作状态等对齐,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。 Windows 安全回归:plugin_host::launch 的版本探测必须在父进程退出、后代继承管道时仍有界结束并清理进程树;web 回归验证真实文档来源与工作区文件目标。 Windows 语言资源整合:声明式插件的运行分组、管理页与安装审查使用共享生成词条;中英文回归验证安装名称/版本、程序数量和启停标签,并确认用户值中的占位符保持字面文本。 Windows 宿主修复回归:在 Core startServer 尚未返回时禁用插件,晚到会话必须先停止再拒绝;即时设置携带 pluginId,Core 接受并下推对应会话。缺失 requiredPackagePaths/runtimeRequirements 或 validationArguments 非零退出时拒绝启动。Hello Lithe 文本窗口、菜单、命令面板和工具栏可见,禁用后全部撤销;停靠、图标、动作状态及持久布局仍待接入,保持 partial。" + "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移未完成,宿主 UI 仅完成文本和命令入口,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 仍缺停靠、图标和动作状态等对齐,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。 Windows 安全回归:plugin_host::launch 的版本探测必须在父进程退出、后代继承管道时仍有界结束并清理进程树;web 回归验证真实文档来源与工作区文件目标。 Windows 语言资源整合:声明式插件的运行分组、管理页与安装审查使用共享生成词条;中英文回归验证安装名称/版本、程序数量和启停标签,并确认用户值中的占位符保持字面文本。 Windows 宿主修复回归:在 Core startServer 尚未返回时禁用插件,晚到会话必须先停止再拒绝;即时设置携带 pluginId,Core 接受并下推对应会话。缺失 requiredPackagePaths/runtimeRequirements 或 validationArguments 非零退出时拒绝启动。Hello Lithe 文本窗口、菜单、命令面板和工具栏可见,禁用后全部撤销;停靠、图标、动作状态及持久布局仍待接入,保持 partial。;Windows 集成检查发现并修复声明式专有后缀无法进入编辑器 LSP 激活门的问题:未被 Java/Worker 认领的本地文件异步查询原生语言归属,实际启动继续复用现有解析与信任检查;停用清除缓存、拒绝迟到答案,并在文档仍打开时释放附件。门控与 hook 回归覆盖启动、禁用及虚拟/无归属路径。验收驱动以保留进程句柄及创建时间/可执行文件身份清理,拒绝按历史 PID 清理无关进程;完整 Release/WebView2 流程仍待本次构建重验,整体保持 pending。" } diff --git a/shared/platform-feature-matrix/features/plugin-detail-tabs.json b/shared/platform-feature-matrix/features/plugin-detail-tabs.json index 407ae0be01..7a64697f70 100644 --- a/shared/platform-feature-matrix/features/plugin-detail-tabs.json +++ b/shared/platform-feature-matrix/features/plugin-detail-tabs.json @@ -18,11 +18,16 @@ }, "windows": { "evidence": [ - "windows/tauri/src/features/settings" + "windows/tauri/src-tauri/src/plugin_host/details.rs", + "windows/tauri/src-tauri/src/plugin_host/details_tests.rs", + "windows/tauri/src/features/plugins/details/plugin-details-view.tsx", + "windows/tauri/src/features/plugins/details/plugin-details.service.test.ts", + "windows/tauri/src/features/plugins/details/plugin-details-view.test.tsx", + "windows/tauri/src/features/plugins/declarative-plugins-section.tsx" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "owner": "Plugin Platform", - "verification": "macOS:打开设置 › 插件 › 已安装,选中 Java Support。头部显示名称、官方徽标、版本和状态胶囊,右侧有启用/禁用、卸载按钮。依次切换五个页签:概览显示状态、提供的能力、来源和设置数量,“在设置中配置”可跳到 LSP 和运行配置页;功能页签列出 Java 语言、语言服务器(使用插件自带的 java 运行)以及由 Lithe 提供的运行、测试、调试;设置页签显示最大内存和日志级别,修改后出现“重启语言服务器”,“恢复默认值”可一键还原;权限与程序页签列出 languageServer.launch 与 JDT LS 启动命令,内容与安装确认弹窗一致;版本与来源显示当前版本、插件 ID 和签名。安装一个社区插件,确认社区提示固定在页签上方,任何页签都能看到。在左侧点“语言服务”“社区 / 磁盘”“已禁用”等过滤芯片,确认列表随之变化并按“已启用 / 已禁用”分组;在搜索框输入 /,确认出现过滤选项,选中后搜索框清空并切换过滤。更新过的插件在版本与来源页签显示上一版本和“回滚…”,确认后需要重启 Lithe。布局:在浅色和深色下缩放设置窗口、往返拖动插件列表分隔条,切换 Java/Cargo 与各页签,确认标题、页签和正文不越过分隔线;窄设置表单可横向滚动至末尾,输入控件保持同一实例,扩大后恢复填满正文。运行 PluginDetailLayoutTests 检查这些边界。Windows:扩展管理尚未拆分页签。" + "verification": "macOS:打开设置 › 插件 › 已安装,选中 Java Support。头部显示名称、官方徽标、版本和状态胶囊,右侧有启用/禁用、卸载按钮。依次切换五个页签:概览显示状态、提供的能力、来源和设置数量,“在设置中配置”可跳到 LSP 和运行配置页;功能页签列出 Java 语言、语言服务器(使用插件自带的 java 运行)以及由 Lithe 提供的运行、测试、调试;设置页签显示最大内存和日志级别,修改后出现“重启语言服务器”,“恢复默认值”可一键还原;权限与程序页签列出 languageServer.launch 与 JDT LS 启动命令,内容与安装确认弹窗一致;版本与来源显示当前版本、插件 ID 和签名。安装一个社区插件,确认社区提示固定在页签上方,任何页签都能看到。在左侧点“语言服务”“社区 / 磁盘”“已禁用”等过滤芯片,确认列表随之变化并按“已启用 / 已禁用”分组;在搜索框输入 /,确认出现过滤选项,选中后搜索框清空并切换过滤。更新过的插件在版本与来源页签显示上一版本和“回滚…”,确认后需要重启 Lithe。布局:在浅色和深色下缩放设置窗口、往返拖动插件列表分隔条,切换 Java/Cargo 与各页签,确认标题、页签和正文不越过分隔线;窄设置表单可横向滚动至末尾,输入控件保持同一实例,扩大后恢复填满正文。运行 PluginDetailLayoutTests 检查这些边界。Windows:运行 WindowsRust 与 Frontend 计时 harness,确认真实扫描夹具覆盖合法包、隔离记录、摘要变化、上一版本来源;前端覆盖五页签、完整程序声明、设置编辑器复用、无选中项、迟到成功/失败与操作失败。在已挂载 PluginDetailsPanel 的声明式插件管理页中,使用中英文、浅深主题、键盘和窄窗口下验收启停、卸载、回滚与错误反馈。市场更新数据接入 M2 前,版本页必须保持“未知”而不是显示可更新;列表筛选与市场更新数据仍待后续整合,所以 Windows 保持 partial/pending。;Windows 集成后五页签已挂到声明式插件管理页,点击插件名称选中,操作后重新扫描并刷新详情,卸载清除选中项,刷新失败进入管理页错误反馈。服务与视图定向回归通过;市场更新数据仍显示未知,筛选、搜索与中英文/主题/键盘/窄窗实机验收待完成,Windows 保持 partial/pending。" } diff --git a/shared/platform-feature-matrix/features/plugin-marketplace.json b/shared/platform-feature-matrix/features/plugin-marketplace.json index c6ba96c3fc..3c216665b9 100644 --- a/shared/platform-feature-matrix/features/plugin-marketplace.json +++ b/shared/platform-feature-matrix/features/plugin-marketplace.json @@ -46,15 +46,23 @@ "evidence": [ "windows/tauri/src-tauri/src/plugin_host/install.rs", "windows/tauri/src-tauri/src/plugin_host/install_tests.rs", + "windows/tauri/src-tauri/src/plugin_host/marketplace_sources.rs", + "windows/tauri/src-tauri/src/plugin_host/marketplace_sources_tests.rs", "windows/tauri/src/features/plugins/declarative-plugin-launch.ts", "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", + "windows/tauri/src/features/plugins/declarative-marketplace-sources.ts", + "windows/tauri/src/features/plugins/declarative-marketplace-sources-section.tsx", + "windows/tauri/src/features/plugins/declarative-marketplace-sources.test.ts", + "windows/tauri/src/features/plugins/declarative-marketplace-sources.test.tsx", "rust/lithe-core/src/plugins/marketplace.rs", - "shared/contracts/plugins/v1/fixtures/marketplace" + "shared/contracts/plugins/v1/fixtures/marketplace", + "windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs", + "windows/tauri/src-tauri/src/plugin_host/marketplace_download.rs" ], "implementationStatus": "partial", - "verificationStatus": "not-applicable" + "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "macOS 已在隔离实例实际验证社区 ZIP/目录选择与确认、本地来源和摘要、完整程序 JSON 展开、同版本再次确认及取消不改原记录、安装字节、重启扫描与 bundle 哈希不变;磁盘信任及受影响安装流程已运行定向回归,后台审查通过工作线程独占 Store 与文件管理器保持界面可取消,取消后等待读取结束再清理快照。页面关闭与迟到结果清理由状态回归覆盖,完整市场源管理、双语和全部磁盘组合尚未逐项实机验收,因此整体 verificationStatus 保持 pending。最终 Maven/Gradle 新清单还依赖待合入的共享契约和宿主能力,本次 preview 基线的受控包验收不替代最终插件整体验收。Windows 未实现本次声明式磁盘安装链,partial 不表示可以运行 macOS 流程。", - "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;弹窗按设计稿呈现:仓库列在圆角卡片里,每行是名称(旁边有标签:“Lithe 官方”和预置的 Lithe Store 为“官方”,用户添加的仓库为“社区”)、等宽索引地址和状态行(“已于 刚刚 刷新”随时间变为“5 分钟前”,超过一周显示日期;有被忽略条目时追加“已忽略条目:N”,停用的源标注“已禁用”,刷新失败显示原因),右侧依次是启用开关、“刷新”和红色“移除”;“Lithe 官方”行只能停用,用灰色“默认”代替“移除”;底部左侧“全部刷新”,右侧“完成”。第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 已实现契约插件的磁盘导入链:ZIP/目录→私有快照(拒绝 reparse point、路径逃逸、超限文件与恶意 ZIP 条目)→plugins.marketplace.installReview 全量审查(权限+完整程序清单,界面明示未认证发布者)→确认时摘要比对拒绝快照调包→staging 复验后激活→卸载/回滚/启停。磁盘导入的取消清理、快照篡改拒绝与 staging 失败清理由 Rust 测试覆盖;多仓库源市场 UI 仍缺失(M3+),实机验收前 verificationStatus 保持 not-applicable。Worker 扩展模型的非语言市场安装命令(install_extension_from_url 等)现注册为显式占位错误,等待独立后端。;市场列表与详情之间的分隔条使用真实页面回归:中英文描述与深浅主题下缩放窗口、拖动后释放,再刷新索引,详情视口始终位于分隔条右侧;正文按视口宽度换行,窄于来源选择器和提示卡所需宽度时可横向滚动至末端,短详情保持顶部对齐。测试使用私有临时偏好域,关闭窗口后清理;仅原生组件证据,整体实机验收仍 pending。" + "notes": "macOS 已在隔离实例实际验证社区 ZIP/目录选择与确认、本地来源和摘要、完整程序 JSON 展开、同版本再次确认及取消不改原记录、安装字节、重启扫描与 bundle 哈希不变;磁盘信任及受影响安装流程已运行定向回归,后台审查通过工作线程独占 Store 与文件管理器保持界面可取消,取消后等待读取结束再清理快照。页面关闭与迟到结果清理由状态回归覆盖,完整市场源管理、双语和全部磁盘组合尚未逐项实机验收,因此整体 verificationStatus 保持 pending。最终 Maven/Gradle 新清单还依赖待合入的共享契约和宿主能力,本次 preview 基线的受控包验收不替代最终插件整体验收。Windows 已实现声明式磁盘安装、仓库源管理及内部索引网络层,市场目录与安装入口仍未接通;partial 不表示完整市场已交付。", + "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;弹窗按设计稿呈现:仓库列在圆角卡片里,每行是名称(旁边有标签:“Lithe 官方”和预置的 Lithe Store 为“官方”,用户添加的仓库为“社区”)、等宽索引地址和状态行(“已于 刚刚 刷新”随时间变为“5 分钟前”,超过一周显示日期;有被忽略条目时追加“已忽略条目:N”,停用的源标注“已禁用”,刷新失败显示原因),右侧依次是启用开关、“刷新”和红色“移除”;“Lithe 官方”行只能停用,用灰色“默认”代替“移除”;底部左侧“全部刷新”,右侧“完成”。第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 已实现契约插件的磁盘导入链:ZIP/目录→私有快照(拒绝 reparse point、路径逃逸、超限文件与恶意 ZIP 条目)→plugins.marketplace.installReview 全量审查(权限+完整程序清单,界面明示未认证发布者)→确认时摘要比对拒绝快照调包→staging 复验后激活→卸载/回滚/启停。磁盘导入的取消清理、快照篡改拒绝与 staging 失败清理由 Rust 测试覆盖。声明式市场的源管理切片已实现:源列表/添加/启停/删除与原子持久化(app_local_data_dir/plugin-marketplace/sources.json),官方源可停用不可删除,Lithe Store 预置可删除且重启不复活(墓碑记录),手工添加的同源(含大小写/尾斜杠变体)去重并保留启用状态,稳定源 ID 与 macOS 同用 FNV-1a 标准答案,添加 URL 经 Core plugins.marketplace.resolveSource 解析;损坏的源配置保留原文件(sources.json.corrupt)并给出可诊断问题。索引刷新及 ZIP 下载已有内部网络模块,生产市场目录、安装、升级与信任标记入口仍缺失(M2 后续切片/M3+),不冒充已可安装。Worker 扩展模型的非语言市场安装命令(install_extension_from_url 等)现注册为显式占位错误,等待独立后端。Windows 源管理切片的验证动作:cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml marketplace_sources(预置重建、FNV 固定答案、Core resolveSource 标准答案、去重与大小写变体拒绝、官方拒删、Lithe Store 墓碑/再添加/手工同源替身、损坏文件保全、启停持久化与无临时文件残留)与 bun test src/features/plugins/declarative-marketplace-sources(service 命令/载荷回归 + 管理组件渲染与调用回归,中英文词条),bun run typecheck;在真实 Windows 上完成“添加→显示→停用→重启保持→删除→重启不复活”的设置页实机验收前,Windows verificationStatus 保持 pending。;市场列表与详情之间的分隔条使用真实页面回归:中英文描述与深浅主题下缩放窗口、拖动后释放,再刷新索引,详情视口始终位于分隔条右侧;正文按视口宽度换行,窄于来源选择器和提示卡所需宽度时可横向滚动至末端,短详情保持顶部对齐。测试使用私有临时偏好域,关闭窗口后清理;仅原生组件证据,整体实机验收仍 pending。;M2-A 审查修复回归:declarative-marketplace-sources.test.tsx 验证首次读取失败后重试成功清除 load 提示,语言切换及过期读取不能清除更新的 mutation 错误。Windows 市场源仍为内部 partial/pending,索引生产入口、下载安装和真实产品验收留待所属批次。;Windows 集成检查(2026-10-25):源管理已接入管理页;添加请求完成时只清除原提交草稿,不覆盖用户后来输入。索引与 ZIP 网络层包含受限重定向、流式上限、摘要校验、取消/超时、缓存原始索引的能力及来源重校验、迟到刷新拒绝和临时文件清理;真实本地 HTTP 与共享标准答案回归已通过。网络模块尚无生产 IPC/目录与安装事务消费者,市场浏览、下载安装、更新和固定来源策略留待下一批,Windows 保持 partial/pending。" } diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 446ed06df8..b317dd7231 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -144,6 +144,7 @@ fn main() { app.manage(run::RunProcessManager::default()); app.manage(debug::DebugAdapterManager::default()); app.manage(plugin_host::PluginHostState::default()); + app.manage(plugin_host::build::BuildHostState::default()); app.manage(plugin_host::web::WebPageHostState::default()); app.manage(plugin_host::lifecycle::LifecycleState::default()); run::cleanup_legacy_appdata(app.handle()); @@ -299,6 +300,7 @@ fn main() { plugin_host::lifecycle::declarative_plugins_lifecycle_ack, plugin_host::declarative_plugins_rescan, plugin_host::declarative_plugins_state, + plugin_host::details::declarative_plugins_details, plugin_host::web::declarative_plugins_web_open_file_ack, plugin_host::web::declarative_plugins_web_resolve_file, plugin_host::web::declarative_plugins_web_update_appearance, @@ -314,8 +316,18 @@ fn main() { plugin_host::settings::declarative_plugins_resolve_settings, plugin_host::settings::declarative_plugins_store_setting, plugin_host::settings::declarative_plugins_workspace_configuration, + plugin_host::marketplace_sources::declarative_marketplace_sources_state, + plugin_host::marketplace_sources::declarative_marketplace_sources_add, + plugin_host::marketplace_sources::declarative_marketplace_sources_set_enabled, + plugin_host::marketplace_sources::declarative_marketplace_sources_remove, plugin_host::run_plans::declarative_plugins_run_plan, plugin_host::run_plans::declarative_plugins_test_plan, + plugin_host::build::declarative_plugins_build_catalog, + plugin_host::build::declarative_plugins_build_refresh, + plugin_host::build::declarative_plugins_build_set_option, + plugin_host::build::declarative_plugins_build_sync_check, + plugin_host::build::declarative_plugins_build_plugins_changed, + plugin_host::build::declarative_plugins_build_close_workspace, plugin_host::web::declarative_plugins_close_workspace_pages, plugin_host::web::declarative_plugins_web_windows, plugin_host::web::declarative_plugins_open_web_window, diff --git a/windows/tauri/src-tauri/src/plugin_host/build.rs b/windows/tauri/src-tauri/src/plugin_host/build.rs new file mode 100644 index 0000000000..bc34d4fc9a --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build.rs @@ -0,0 +1,340 @@ +//! Tauri commands for the declarative build-system registry (M4-A). +//! +//! This batch wires the read-only path only: detection, static models, +//! option resolution and persistence, and sync fingerprints. No plugin +//! process runs from here, and the host still does not declare the +//! `buildSystems` capability, so no user-visible entry exists until the +//! remaining M4 batches land. Package trust is re-verified through M1's +//! launch gate (`verified_package_by_id`) on every call, the caller's window +//! label comes from the injected Tauri window (never from the frontend), and +//! the blocking Core/filesystem work runs on the async runtime instead of +//! the UI thread. + +use super::build_registry::{ + BuildCoreExecutor, BuildOptionsPersistence, BuildPackageInput, BuildPackageSource, + BuildRegistry, StoredBuildOptions, +}; +use super::store; +use serde::Serialize; +use serde_json::Value; +use std::sync::Mutex; +use tauri::{AppHandle, Manager, WebviewWindow}; + +/// Registry shared by every window; entries are keyed per window label. +#[derive(Default)] +pub struct BuildHostState { + registry: Mutex, +} + +/// Executes Core through the shared host envelope. +struct HostCoreExecutor; + +impl BuildCoreExecutor for HostCoreExecutor { + fn execute(&self, command: &str, payload: Value) -> Result { + super::scan::core_execute(command, payload) + } +} + +/// Every build-visible package passes M1's production trust gate: accepted +/// in the scan, enabled, not lifecycle-blocked, and re-verified against the +/// live bytes (`verified_package_by_id`). While `hostCapabilities` keeps +/// `buildSystems` closed, Core's validate gate rejects such packages, so +/// this stays empty in production; tests inject fixture manifests instead. +fn verified_build_packages(app: &AppHandle) -> Vec { + let mut packages = Vec::new(); + for candidate in app.state::().packages() { + if candidate.entry.status != "accepted" + || !candidate.entry.enabled + || app + .state::() + .blocked(&candidate.entry.plugin_id) + { + continue; + } + // Re-run the launch trust check per package; a quarantined or + // tampered package is skipped here instead of surfacing later. + if let Ok(package) = super::launch::verified_package_by_id(app, &candidate.entry.plugin_id) + { + if package.manifest["buildSystems"] + .as_array() + .is_some_and(|systems| !systems.is_empty()) + { + packages.push(package); + } + } + } + packages +} + +fn package_input(package: &super::ScannedPackage) -> BuildPackageInput { + BuildPackageInput { + plugin_id: package.entry.plugin_id.clone(), + version: package.entry.version.clone(), + manifest: package.manifest.clone(), + } +} + +/// The verified build package source used by the registry. +struct HostPackageSource { + app: AppHandle, +} + +impl BuildPackageSource for HostPackageSource { + fn accepted_packages(&self) -> Vec { + verified_build_packages(&self.app) + .iter() + .map(package_input) + .collect() + } + + fn package(&self, plugin_id: &str) -> Option { + verified_build_packages(&self.app) + .iter() + .find(|package| package.entry.plugin_id == plugin_id) + .map(package_input) + } +} + +/// Build option values per workspace under +/// `app_local_data_dir()/plugin-build-options/.json`, beside +/// the other plugin state; nothing is written into packages or the install +/// directory. +struct HostOptionsPersistence { + app: AppHandle, +} + +impl HostOptionsPersistence { + fn store_path(&self, workspace_root: &str) -> Result { + Ok(self + .app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-build-options") + .join(format!( + "{}.json", + store::workspace_state_key(workspace_root) + ))) + } +} + +impl BuildOptionsPersistence for HostOptionsPersistence { + fn load(&self, workspace_root: &str) -> Result { + let path = self.store_path(workspace_root)?; + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(StoredBuildOptions::default()) + } + Err(error) => return Err(error.to_string()), + }; + serde_json::from_str(&text) + .map_err(|error| format!("{} is not valid JSON: {error}", path.display())) + } + + fn save(&self, workspace_root: &str, options: &StoredBuildOptions) -> Result<(), String> { + let path = self.store_path(workspace_root)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let temp = path.with_extension("json.tmp"); + std::fs::write( + &temp, + serde_json::to_string_pretty(options).map_err(|e| e.to_string())?, + ) + .map_err(|error| error.to_string())?; + std::fs::rename(&temp, &path).map_err(|error| error.to_string()) + } +} + +/// One plugin's declared build systems, for tool-window registration only. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildCatalogEntry { + pub plugin_id: String, + pub plugin_display_name: String, + pub build_system_id: String, + pub build_system_display_name: String, + /// Presentation icon path as declared; the host does not interpret it. + pub icon: Option, +} + +/// Declared build systems of packages that pass the trust gate right now. +/// This is a structural listing for window registration; Core validates +/// every declaration again on the next `build.detect`. +#[tauri::command] +pub async fn declarative_plugins_build_catalog( + app: AppHandle, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let mut entries = Vec::new(); + for package in verified_build_packages(&app) { + for system in package.manifest["buildSystems"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + { + let id = system["id"].as_str().unwrap_or_default(); + if id.is_empty() { + continue; + } + entries.push(BuildCatalogEntry { + plugin_id: package.entry.plugin_id.clone(), + plugin_display_name: package.entry.display_name.clone(), + build_system_id: id.to_string(), + build_system_display_name: system["displayName"] + .as_str() + .unwrap_or(id) + .to_string(), + icon: system["icon"].as_str().map(str::to_string), + }); + } + } + Ok(entries) + }) + .await + .map_err(|error| format!("build catalog task failed: {error}"))? +} + +/// Re-detects build-system instances for the calling window and workspace. +/// The window label comes from the injected window, never the frontend. +#[tauri::command] +pub async fn declarative_plugins_build_refresh( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, +) -> Result { + let window_label = window.label().to_string(); + run_build_task(app, move |registry, source, persistence| { + let view = registry.refresh( + &HostCoreExecutor, + source, + persistence, + &window_label, + &workspace_root, + )?; + serde_json::to_value(view).map_err(|error| error.to_string()) + }) + .await +} + +/// Applies one option edit (or reset with `value: null`). The candidate is +/// validated through Core before anything is persisted. +#[tauri::command] +pub async fn declarative_plugins_build_set_option( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, + option_id: String, + value: Option, +) -> Result { + let window_label = window.label().to_string(); + run_build_task(app, move |registry, source, persistence| { + let view = registry.set_option( + &HostCoreExecutor, + source, + persistence, + &window_label, + &workspace_root, + &plugin_id, + &build_system_id, + &root, + &option_id, + value, + )?; + serde_json::to_value(view).map_err(|error| error.to_string()) + }) + .await +} + +/// Recomputes one instance's sync fingerprint; the frontend debounces this +/// on workspace file changes. +#[tauri::command] +pub async fn declarative_plugins_build_sync_check( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, +) -> Result { + let window_label = window.label().to_string(); + run_build_task(app, move |registry, source, persistence| { + let view = registry.sync_check( + &HostCoreExecutor, + source, + &window_label, + &workspace_root, + &plugin_id, + &build_system_id, + &root, + )?; + serde_json::to_value(view).map_err(|error| error.to_string()) + }) + .await +} + +/// Revokes every session of one plugin across all windows. Called from the +/// frontend lifecycle listener when a plugin is disabled, replaced, rolled +/// back, or uninstalled, so stored sessions can never answer again. +#[tauri::command] +pub async fn declarative_plugins_build_plugins_changed( + app: AppHandle, + window: WebviewWindow, + plugin_id: String, +) -> Result<(), String> { + let window_label = window.label().to_string(); + run_build_task(app, move |registry, _source, _persistence| { + registry.drop_plugin(&plugin_id); + let _ = window_label; + Ok(()) + }) + .await +} + +/// Clears the calling window's sessions when its workspace closed, so +/// reopening the same path cannot reuse the pre-close sessions. +#[tauri::command] +pub async fn declarative_plugins_build_close_workspace( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, +) -> Result<(), String> { + let window_label = window.label().to_string(); + run_build_task(app, move |registry, _source, _persistence| { + registry.close_workspace(&window_label, &workspace_root); + Ok(()) + }) + .await +} + +/// Runs one registry operation on the async runtime's blocking pool: Core +/// calls and file I/O must never occupy the UI thread, and the registry +/// mutex is only held inside the spawned task. +async fn run_build_task(app: AppHandle, operation: F) -> Result +where + T: Send + 'static, + F: FnOnce( + &mut BuildRegistry, + &dyn BuildPackageSource, + &dyn BuildOptionsPersistence, + ) -> Result + + Send + + 'static, +{ + tauri::async_runtime::spawn_blocking(move || { + let source = HostPackageSource { app: app.clone() }; + let persistence = HostOptionsPersistence { app: app.clone() }; + let state = app.state::(); + let mut registry = state + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")?; + operation(&mut registry, &source, &persistence) + }) + .await + .map_err(|error| format!("build task failed: {error}"))? +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build_registry.rs b/windows/tauri/src-tauri/src/plugin_host/build_registry.rs new file mode 100644 index 0000000000..d7a0340a93 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build_registry.rs @@ -0,0 +1,887 @@ +//! Windows-side build session registry for declarative build systems. +//! +//! One registry holds per-window build facts (instances, static models, +//! option descriptions, sync digests) keyed by window label and workspace +//! root. Every semantic interpretation stays in Core: this module only +//! shapes `build.*` requests, applies the host storage rules (values shared +//! per build system plus per-instance explicit-empty markers), and refuses +//! writes whose window has already moved to another workspace. Package +//! identity is re-checked against the injected source before every write, +//! so a disabled, replaced, or quarantined package never keeps answering +//! from a stale session. Detection and option resolution run no processes; +//! the capability declaration stays closed until the full milestone. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +/// One verified package as the build pipeline should see it. The version +/// comes from the same trust check as the manifest, so a session can detect +/// a package replacement between operations. +#[derive(Debug, Clone)] +pub struct BuildPackageInput { + pub plugin_id: String, + pub version: String, + pub manifest: Value, +} + +/// Executes one Core `build.*` command through the shared envelope. +pub trait BuildCoreExecutor: Send + Sync { + fn execute(&self, command: &str, payload: Value) -> Result; +} + +/// The verified build packages. Production re-verifies trust against the +/// live bytes (M1's launch gate) on every call; tests inject fixture +/// manifests, which is the only sanctioned way to exercise build +/// declarations while the capability is closed. +pub trait BuildPackageSource: Send + Sync { + fn accepted_packages(&self) -> Vec; + + /// The current verified package for one plugin, or `None` when it is no + /// longer accepted, enabled, or trusted. + fn package(&self, plugin_id: &str) -> Option; +} + +/// Persists build option values per workspace. Values are shared per build +/// system; explicit empty multi-select choices are kept as per-instance +/// markers so sibling instances never lose their own selections. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredBuildOptions { + pub systems: BTreeMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredSystemOptions { + /// Shared stored values by option ID, in the `build.detect` shape. + pub values: BTreeMap, + /// Instance roots whose user explicitly cleared a multi-select option; + /// the marker wins over a non-empty default until reset or re-edit. + pub empty_markers: BTreeMap>, +} + +pub trait BuildOptionsPersistence: Send + Sync { + fn load(&self, workspace_root: &str) -> Result; + fn save(&self, workspace_root: &str, options: &StoredBuildOptions) -> Result<(), String>; +} + +/// One detected build-system instance with its host-side session state. +#[derive(Debug, Clone)] +struct BuildSession { + plugin_id: String, + build_system_id: String, + root: String, + /// Package version the session was built from; a later mismatch means + /// the package was replaced and the session must not be trusted. + version: String, + /// The manifest of the owning package, for plan-shaped Core calls. + manifest: Value, + /// Core's detect instance, including the static model when it has one. + instance: Value, + /// `build.resolveOptions` description: kinds, choices, effective values. + options_description: Value, + /// Effective values (markers applied) as a plain map for sync requests. + effective_options: BTreeMap, + /// Model-declared choice values, carried into option resolution. + model_values: BTreeMap>, + /// Last `build.syncFingerprint` digest; `None` before the first check. + sync_digest: Option, + /// True when a later digest differs from the one the model was built + /// with; the model stays visible until the user refreshes. + stale: bool, +} + +#[derive(Debug, Default)] +struct WindowState { + workspace_root: Option, + generation: u64, + /// Keyed by (plugin ID, build system ID, instance root). + sessions: BTreeMap<(String, String, String), BuildSession>, + issues: Vec, +} + +/// Immutable snapshot for one window: sessions and issues as JSON. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildView { + pub generation: u64, + pub workspace_root: String, + pub instances: Vec, + pub issues: Vec, +} + +#[derive(Debug, Default)] +pub struct BuildRegistry { + windows: HashMap, +} + +impl BuildRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Re-detects every instance of the workspace for one window. A changed + /// workspace root resets the window's sessions and bumps its generation + /// so responses already in flight are dropped by the frontend. + pub fn refresh( + &mut self, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + persistence: &dyn BuildOptionsPersistence, + window_label: &str, + workspace_root: &str, + ) -> Result { + { + let window = self.windows.entry(window_label.to_string()).or_default(); + let switched = window.workspace_root.is_some() + && window.workspace_root.as_deref() != Some(workspace_root); + if switched { + window.generation += 1; + window.sessions.clear(); + window.issues.clear(); + } + window.workspace_root = Some(workspace_root.to_string()); + } + + let manifests = packages.accepted_packages(); + let mut stored = persistence.load(workspace_root)?; + let mut changed = false; + let request_options: BTreeMap> = stored + .systems + .iter() + .map(|(system, options)| (system.clone(), options.values.clone())) + .collect(); + let response = core.execute( + "build.detect", + json!({ + "workspaceRoot": workspace_root, + "plugins": manifests + .iter() + .map(|package| json!({ "manifest": package.manifest })) + .collect::>(), + "options": request_options, + }), + )?; + let detection = outcome_value(&response, "build.detect")?; + let detection = &detection["detection"]; + + let mut issues = Vec::new(); + for rejected in detection["rejected"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + { + let mut issue = rejected.clone(); + // Core reports manifest rejections by request index; map back to + // the plugin ID so the UI can name the package. + if let Some(package) = rejected["index"] + .as_u64() + .and_then(|index| usize::try_from(index).ok()) + .and_then(|index| manifests.get(index)) + { + issue["pluginID"] = json!(package.plugin_id); + } + issue["kind"] = json!("rejected"); + issues.push(issue); + } + for shadowed in detection["shadowed"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + { + let mut issue = shadowed.clone(); + issue["kind"] = json!("shadowed"); + issues.push(issue); + } + + let mut sessions = BTreeMap::new(); + for instance in detection["instances"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + { + let plugin_id = instance["pluginID"].as_str().unwrap_or_default(); + let build_system_id = instance["buildSystemID"].as_str().unwrap_or_default(); + let root = instance["root"].as_str().unwrap_or_default(); + if plugin_id.is_empty() || build_system_id.is_empty() || root.is_empty() { + continue; + } + let package = manifests + .iter() + .find(|package| package.plugin_id == plugin_id); + let system = stored + .systems + .entry(build_system_id.to_string()) + .or_default(); + // Resolve against one snapshot of the shared values so a later + // instance never sees an earlier instance's dropped adjustment. + let mut session = resolve_session( + core, + instance, + package, + &system.values.clone(), + system.empty_markers.get(root).cloned().unwrap_or_default(), + )?; + // Detection discovers roots and model-dependent options. Bracket + // the authoritative model read with fingerprints: taking only a + // digest after detection could bless bytes changed after the + // model was read as its baseline and miss the first watcher event. + let before = sync_fingerprint(core, workspace_root, &session, &session.manifest)?; + if matches!( + instance["modelSource"].as_str(), + Some("static" | "hostIntegration") + ) { + let response = core.execute( + "build.decodeModel", + json!({ + "manifest": session.manifest, + "buildSystemID": build_system_id, + "request": { + "workspaceRoot": workspace_root, + "root": root, + "options": session.effective_options, + }, + }), + )?; + let mut loaded = instance.clone(); + loaded + .as_object_mut() + .expect("detected instance") + .remove("model"); + loaded + .as_object_mut() + .expect("detected instance") + .remove("modelError"); + if response["status"] == "ok" { + let outcome = outcome_value(&response, "build.decodeModel")?; + loaded["model"] = outcome["model"].clone(); + } else { + loaded["modelError"] = response["error"].clone(); + } + session = resolve_session( + core, + &loaded, + package, + &system.values, + system.empty_markers.get(root).cloned().unwrap_or_default(), + )?; + } + let after = sync_fingerprint(core, workspace_root, &session, &session.manifest)?; + session.stale = before != after; + session.sync_digest = Some(after); + sessions.insert( + ( + plugin_id.to_string(), + build_system_id.to_string(), + root.to_string(), + ), + session, + ); + } + + // A stored value only changes when no instance accepts it anymore; + // per-instance drops keep sibling-only selections in the shared + // store, and each instance filters them through its own model. + for (system_id, instances) in sessions_by_system(&sessions) { + let Some(system) = stored.systems.get_mut(system_id) else { + continue; + }; + let descriptions: Vec = instances + .iter() + .map(|session| session.options_description.clone()) + .collect(); + for (option_id, value) in fully_dropped_adjustments_from(&descriptions) { + system.values.insert(option_id.to_string(), value); + changed = true; + } + } + + if changed { + persistence.save(workspace_root, &stored)?; + } + let window = self.windows.entry(window_label.to_string()).or_default(); + window.sessions = sessions; + window.issues = issues; + Ok(self.view(window_label)) + } + + /// Applies one user option edit. The candidate storage is validated + /// through Core before anything is persisted; a Core rejection, a save + /// failure, or a failed re-detection leaves the previous storage and + /// model untouched. `value: None` resets the option: the stored value + /// and every instance's empty marker for it are removed so the declared + /// default applies again. + #[allow(clippy::too_many_arguments)] + pub fn set_option( + &mut self, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + persistence: &dyn BuildOptionsPersistence, + window_label: &str, + workspace_root: &str, + plugin_id: &str, + build_system_id: &str, + root: &str, + option_id: &str, + value: Option, + ) -> Result { + let fresh = packages + .package(plugin_id) + .ok_or_else(|| format!("{plugin_id} is no longer an enabled plugin"))?; + let kind = { + let window = self + .windows + .get(window_label) + .ok_or_else(|| format!("window {window_label} has no build state"))?; + if window.workspace_root.as_deref() != Some(workspace_root) { + return Err(format!( + "build state for window {window_label} moved to another workspace" + )); + } + let session = window + .sessions + .get(&( + plugin_id.to_string(), + build_system_id.to_string(), + root.to_string(), + )) + .ok_or_else(|| format!("no build instance {build_system_id} at {root}"))?; + if session.version != fresh.version { + return Err(format!( + "{plugin_id} changed since the build window loaded it; reload" + )); + } + option_kind(&session.options_description, option_id) + .ok_or_else(|| format!("option {option_id} is not declared"))? + }; + + let previous = persistence.load(workspace_root)?; + let mut candidate = previous.clone(); + { + let system = candidate + .systems + .entry(build_system_id.to_string()) + .or_default(); + match value { + None => { + // Reset: the shared override and every instance's + // explicit empty marker go away, so the declared default + // applies again. + system.values.remove(option_id); + for markers in system.empty_markers.values_mut() { + markers.remove(option_id); + } + } + Some(value) if kind == "multiSelect" => { + let selection = value + .as_array() + .ok_or_else(|| format!("option {option_id} expects an array"))?; + let own: BTreeSet = selection.iter().filter_map(as_string).collect(); + // Sibling selections survive: the shared array is the + // union of what every instance still accepts, and this + // instance's view is derived from it plus its marker. + let siblings: BTreeSet = self + .windows + .get(window_label) + .into_iter() + .flat_map(|window| window.sessions.values()) + .filter(|other| { + other.build_system_id == build_system_id && other.root != root + }) + .filter_map(|other| other.effective_options.get(option_id).cloned()) + .filter_map(|value| string_list(&value)) + .flatten() + .collect(); + let markers = system.empty_markers.entry(root.to_string()).or_default(); + if own.is_empty() { + markers.insert(option_id.to_string()); + } else { + markers.remove(option_id); + } + let merged: Vec = siblings.union(&own).cloned().collect(); + if merged.is_empty() { + // Nothing selected anywhere: drop the shared entry so + // siblings fall back to defaults while this + // instance's marker keeps its explicit empty value. + system.values.remove(option_id); + } else { + system.values.insert(option_id.to_string(), json!(merged)); + } + } + Some(value) => { + // Non-array kinds keep the explicit value verbatim; an + // empty text value stays an explicit empty choice, not + // "unset". + system.values.insert(option_id.to_string(), value); + system + .empty_markers + .entry(root.to_string()) + .or_default() + .remove(option_id); + } + } + } + + // Core validation before any disk write: every instance of the + // system must resolve the candidate, and the edited option must + // survive for the edited instance. Values Core drops (wrong type, + // unknown choice) are a rejected edit, not a silent rewrite. + let mut validated = Vec::new(); + for session in self + .windows + .get(window_label) + .into_iter() + .flat_map(|window| window.sessions.values()) + .filter(|session| session.build_system_id == build_system_id) + { + let marked = marked_values(&candidate, build_system_id, session); + let description = resolve_options( + core, + &fresh.manifest, + build_system_id, + &marked, + &session.model_values, + )?; + if session.root == root + && description["dropped"] + .as_array() + .map(|dropped| dropped.contains(&json!(option_id))) + .unwrap_or(false) + { + return Err(format!( + "option {option_id} was rejected by Core for instance {root}" + )); + } + validated.push(description); + } + let system = candidate + .systems + .get_mut(build_system_id) + .expect("candidate system exists after the edit"); + for (option_id, value) in fully_dropped_adjustments_from(&validated) { + system.values.insert(option_id, value); + } + + // First and only disk write, then re-detect; a failed refresh rolls + // the storage back so the model and the file stay consistent. + persistence.save(workspace_root, &candidate)?; + self.refresh(core, packages, persistence, window_label, workspace_root) + .map_err(|error| { + if persistence.save(workspace_root, &previous).is_err() { + return format!("{error}; the previous build options could not be restored"); + } + error + }) + } + + /// Recomputes one instance's sync fingerprint and flags the model stale + /// when it changed. The package is re-verified first, and a replaced + /// package version refuses to answer from the old session. + pub fn sync_check( + &mut self, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + window_label: &str, + workspace_root: &str, + plugin_id: &str, + build_system_id: &str, + root: &str, + ) -> Result { + let key = ( + plugin_id.to_string(), + build_system_id.to_string(), + root.to_string(), + ); + let session = { + let window = self + .windows + .get(window_label) + .ok_or_else(|| format!("window {window_label} has no build state"))?; + if window.workspace_root.as_deref() != Some(workspace_root) { + return Err(format!( + "build state for window {window_label} moved to another workspace" + )); + } + window + .sessions + .get(&key) + .ok_or_else(|| { + format!( + "no build instance {build_system_id} at {root} in window {window_label}" + ) + })? + .clone() + }; + let fresh = packages + .package(plugin_id) + .ok_or_else(|| format!("{plugin_id} is no longer an enabled plugin"))?; + if fresh.version != session.version { + return Err(format!( + "{plugin_id} changed since the build window loaded it; reload" + )); + } + + // Use the current verified manifest, including same-version reinstalls. + let digest = sync_fingerprint(core, workspace_root, &session, &fresh.manifest)?; + + let window = self + .windows + .get_mut(window_label) + .ok_or_else(|| format!("window {window_label} has no build state"))?; + let session = window + .sessions + .get_mut(&key) + .ok_or_else(|| format!("no build instance {build_system_id} at {root}"))?; + match &session.sync_digest { + Some(previous) if *previous != digest => session.stale = true, + _ => {} + } + session.sync_digest = Some(digest); + session.manifest = fresh.manifest; + Ok(self.view(window_label)) + } + + /// Drops every session of one plugin across all windows and bumps the + /// affected generations: the revocation path for disable, uninstall, + /// rollback, and same-version replacement. Late responses and stored + /// sessions of the removed plugin can no longer revive it. + pub fn drop_plugin(&mut self, plugin_id: &str) { + for window in self.windows.values_mut() { + let before = window.sessions.len(); + window + .sessions + .retain(|(owner, _, _), _| owner != plugin_id); + if window.sessions.len() != before { + window.generation += 1; + } + window + .issues + .retain(|issue| issue["pluginID"].as_str() != Some(plugin_id)); + } + } + + /// Clears one window's sessions when its workspace closed, so reopening + /// the same path cannot silently reuse the pre-close sessions. + pub fn close_workspace(&mut self, window_label: &str, workspace_root: &str) { + let Some(window) = self.windows.get_mut(window_label) else { + return; + }; + if window.workspace_root.as_deref() == Some(workspace_root) && !window.sessions.is_empty() { + window.sessions.clear(); + window.issues.clear(); + window.generation += 1; + } + } + + /// Snapshot of one window's build state. + pub fn view(&self, window_label: &str) -> BuildView { + let empty = WindowState::default(); + let window = self.windows.get(window_label).unwrap_or(&empty); + let instances = window + .sessions + .values() + .map(|session| { + let mut value = session.instance.clone(); + value["stale"] = json!(session.stale); + value["syncDigest"] = session + .sync_digest + .as_ref() + .map(|digest| json!(digest)) + .unwrap_or(Value::Null); + value["options"] = session.options_description["options"].clone(); + value["optionFingerprint"] = session.options_description["fingerprint"].clone(); + value + }) + .collect(); + BuildView { + generation: window.generation, + workspace_root: window.workspace_root.clone().unwrap_or_default(), + instances, + issues: window.issues.clone(), + } + } +} + +/// Reads the Core-owned file and option fingerprint for this exact model. +fn sync_fingerprint( + core: &dyn BuildCoreExecutor, + workspace_root: &str, + session: &BuildSession, + manifest: &Value, +) -> Result { + let projects: Vec<&str> = session.instance["model"]["projects"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(|project| project["path"].as_str()) + .collect(); + let response = core.execute( + "build.syncFingerprint", + json!({ + "manifest": manifest, + "buildSystemID": session.build_system_id, + "request": { + "workspaceRoot": workspace_root, + "root": session.root, + "projects": projects, + "options": session.effective_options, + }, + }), + )?; + let outcome = outcome_value(&response, "build.syncFingerprint")?; + outcome["sync"]["fingerprint"] + .as_str() + .map(str::to_string) + .ok_or_else(|| "build.syncFingerprint returned no fingerprint".to_string()) +} + +/// Builds one session: re-resolves options per instance so per-instance +/// markers, model choices, and dropped values are applied through Core. +fn resolve_session( + core: &dyn BuildCoreExecutor, + instance: &Value, + package: Option<&BuildPackageInput>, + shared_values: &BTreeMap, + markers: BTreeSet, +) -> Result { + let package = package + .ok_or_else(|| { + format!( + "build instance references unknown plugin {}", + instance["pluginID"].as_str().unwrap_or_default() + ) + })? + .clone(); + let build_system_id = instance["buildSystemID"] + .as_str() + .unwrap_or_default() + .to_string(); + let root = instance["root"].as_str().unwrap_or_default().to_string(); + let model_values = model_option_values(instance); + + let description = resolve_options( + core, + &package.manifest, + &build_system_id, + shared_values, + &model_values, + )?; + // Markers may only override options Core still describes as + // multi-select; a declaration change turns the marker into a no-op. + let mut marked = shared_values.clone(); + for option in description["options"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + { + let is_multi = option["kind"].as_str() == Some("multiSelect"); + let is_marked = option["id"].as_str().is_some_and(|id| markers.contains(id)); + if is_multi && is_marked { + if let Some(id) = option["id"].as_str() { + marked.insert(id.to_string(), json!([])); + } + } + } + let description = if marked != *shared_values { + resolve_options( + core, + &package.manifest, + &build_system_id, + &marked, + &model_values, + )? + } else { + description + }; + + Ok(BuildSession { + plugin_id: instance["pluginID"] + .as_str() + .unwrap_or_default() + .to_string(), + build_system_id: build_system_id.clone(), + root: root.clone(), + version: package.version, + manifest: package.manifest, + instance: instance.clone(), + effective_options: effective_map(&description), + options_description: description, + model_values, + sync_digest: None, + stale: false, + }) +} + +/// Stored values with one session's explicit-empty markers applied, using +/// the kinds Core described for that session. +fn marked_values( + stored: &StoredBuildOptions, + build_system_id: &str, + session: &BuildSession, +) -> BTreeMap { + let mut values = stored + .systems + .get(build_system_id) + .map(|system| system.values.clone()) + .unwrap_or_default(); + let markers = stored + .systems + .get(build_system_id) + .and_then(|system| system.empty_markers.get(&session.root)) + .cloned() + .unwrap_or_default(); + if markers.is_empty() { + return values; + } + for option in session.options_description["options"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + { + let is_multi = option["kind"].as_str() == Some("multiSelect"); + let is_marked = option["id"].as_str().is_some_and(|id| markers.contains(id)); + if is_multi && is_marked { + if let Some(id) = option["id"].as_str() { + values.insert(id.to_string(), json!([])); + } + } + } + values +} + +fn resolve_options( + core: &dyn BuildCoreExecutor, + manifest: &Value, + build_system_id: &str, + values: &BTreeMap, + model_values: &BTreeMap>, +) -> Result { + let response = core.execute( + "build.resolveOptions", + json!({ + "manifest": manifest, + "buildSystemID": build_system_id, + "request": { + "options": values, + "modelValues": model_values, + }, + }), + )?; + let outcome = outcome_value(&response, "build.resolveOptions")?; + Ok(outcome["options"].clone()) +} + +/// Effective stored values for options that every instance of the system +/// dropped: Core describes what it still accepts (or the default), and the +/// host re-stores that so the next detection sees the same answer. Options +/// at least one instance still accepts stay untouched, so sibling-only +/// selections survive in the shared store. +fn fully_dropped_adjustments_from(descriptions: &[Value]) -> Vec<(String, Value)> { + let Some(first) = descriptions.first() else { + return Vec::new(); + }; + let Some(dropped) = first["dropped"].as_array() else { + return Vec::new(); + }; + dropped + .iter() + .filter_map(|id| id.as_str()) + .filter(|id| { + descriptions.iter().all(|description| { + description["dropped"] + .as_array() + .map(|list| list.contains(&json!(id))) + .unwrap_or(false) + }) + }) + .filter_map(|id| { + descriptions + .iter() + .find_map(|description| { + description["options"] + .as_array()? + .iter() + .find(|option| option["id"].as_str() == Some(id)) + .map(|option| option["value"].clone()) + }) + .map(|value| (id.to_string(), value)) + }) + .collect() +} + +/// Groups sessions by build-system ID, in deterministic key order. +fn sessions_by_system<'a>( + sessions: &'a BTreeMap<(String, String, String), BuildSession>, +) -> BTreeMap<&'a str, Vec<&'a BuildSession>> { + let mut grouped: BTreeMap<&str, Vec<&BuildSession>> = BTreeMap::new(); + for session in sessions.values() { + grouped + .entry(session.build_system_id.as_str()) + .or_default() + .push(session); + } + grouped +} + +fn effective_map(description: &Value) -> BTreeMap { + description["options"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(|option| { + option["id"] + .as_str() + .map(|id| (id.to_string(), option["value"].clone())) + }) + .collect() +} + +fn option_kind(description: &Value, option_id: &str) -> Option { + description["options"] + .as_array()? + .iter() + .find(|option| option["id"].as_str() == Some(option_id))?["kind"] + .as_str() + .map(str::to_string) +} + +/// Model-declared choice values for `choicesFromModel` options. +fn model_option_values(instance: &Value) -> BTreeMap> { + instance["model"]["optionValues"] + .as_object() + .map(|values| { + values + .iter() + .filter_map(|(id, list)| string_list(list).map(|values| (id.clone(), values))) + .collect() + }) + .unwrap_or_default() +} + +fn string_list(value: &Value) -> Option> { + value + .as_array() + .map(|values| values.iter().filter_map(as_string).collect()) +} + +fn as_string(value: &Value) -> Option { + value.as_str().map(str::to_string) +} + +/// Unwraps the shared `{status, }` envelope; rejected declarations and +/// requests surface as errors with their stable code. +fn outcome_value(response: &Value, command: &str) -> Result { + match response["status"].as_str() { + Some("ok") => Ok(response.clone()), + Some("rejected") => { + let code = response["error"]["code"].as_str().unwrap_or("unknown"); + let message = response["error"]["message"].as_str().unwrap_or("rejected"); + Err(format!("{command} rejected: {code}: {message}")) + } + _ => Err(format!("{command} returned an unexpected status")), + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build_resolver.rs b/windows/tauri/src-tauri/src/plugin_host/build_resolver.rs new file mode 100644 index 0000000000..da3ba810d4 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build_resolver.rs @@ -0,0 +1,1055 @@ +//! Resolves Core-ordered build executable candidates on Windows. +//! +//! Rust Core owns candidate ordering, setting validation, version parsing, and +//! build-system semantics. This adapter only turns an `invocationPlan` into +//! real Windows paths and candidate-local environments. It never starts a +//! process: callers execute each returned probe through the shared bounded +//! process runner, pass its output to `build.checkToolVersion`, and resume the +//! session with that deterministic answer. + +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::fmt; +use std::path::{Component, Path, PathBuf}; + +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; + +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + +/// A host-managed runtime selected by the existing Windows runtime settings. +/// +/// `environment` carries runtime-owned values such as `JAVA_HOME`. The +/// executable directory is added to PATH by this resolver. Explicit inputs +/// are terminal: a stale user selection must not silently switch runtimes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimeExecutableInput { + pub(crate) executable: PathBuf, + pub(crate) environment: BTreeMap, + pub(crate) explicit: bool, +} + +impl RuntimeExecutableInput { + pub(crate) fn discovered(executable: PathBuf) -> Self { + Self { + executable, + environment: BTreeMap::new(), + explicit: false, + } + } +} + +/// Stateless production adapter plus one frozen environment/runtime snapshot. +/// +/// Construct one per operation. That prevents a settings or environment change +/// halfway through resolution from changing the meaning of later candidates. +#[derive(Debug, Clone)] +pub(crate) struct WindowsBuildToolResolver { + environment: BTreeMap, + runtimes: BTreeMap, +} + +impl WindowsBuildToolResolver { + /// Uses the process environment and caller-supplied runtime preferences. + /// Runtime settings stay owned by the existing Windows runtime manager; + /// this module deliberately has no persistence of its own. + pub(crate) fn from_current_process(runtimes: BTreeMap) -> Self { + Self { + environment: std::env::vars().collect(), + runtimes, + } + } + + #[cfg(test)] + pub(crate) fn with_environment( + environment: BTreeMap, + runtimes: BTreeMap, + ) -> Self { + Self { + environment, + runtimes, + } + } + + /// Validates the Core plan and freezes workspace/instance real paths. + pub(crate) fn start( + &self, + invocation: &Value, + workspace_root: &Path, + ) -> Result { + let plan: InvocationPlan = serde_json::from_value(invocation.clone()).map_err(|error| { + BuildResolutionError::invalid_plan(format!("build invocation plan is invalid: {error}")) + })?; + let workspace_root = absolute_existing_directory(workspace_root, "workspace root")?; + let instance_relative = plan_relative_path(&plan.root, "instance root")?; + let instance_path = workspace_root.join(&instance_relative); + reject_reparse_components(&workspace_root, &instance_path)?; + let instance_root = absolute_existing_directory(&instance_path, "build instance root")?; + if !path_is_within(&instance_root, &workspace_root) { + return Err(BuildResolutionError::project_escape(format!( + "build instance root {} escapes workspace {}", + instance_path.display(), + workspace_root.display() + ))); + } + let configured_setting = plan + .executables + .iter() + .chain( + plan.runtime_dependencies + .iter() + .flat_map(|runtime| runtime.executables.iter()), + ) + .any(ExecutableCandidate::has_configured_setting); + // Relative discovery inputs belong to the host's current directory, + // never the workspace that a later probe or build runs in. + let discovery_root = std::env::current_dir().map_err(|error| { + BuildResolutionError::invalid_plan(format!( + "host tool discovery directory could not be resolved: {error}" + )) + })?; + Ok(BuildResolutionSession { + plan, + workspace_root, + instance_root, + discovery_root, + environment: self.environment.clone(), + runtime_executables: BTreeMap::new(), + runtime_index: 0, + runtime_candidate_index: 0, + tool_candidate_index: 0, + pending: None, + problems: Vec::new(), + configured_setting, + }) + } + + /// Advances resolution until a probe is needed or a tool is selected. + /// + /// The first call passes `None`. A `Probe` result must be followed by one + /// call carrying the matching outcome; extra or missing outcomes are a + /// protocol error. No filesystem state other than executable existence is + /// retained between calls. + pub(crate) fn advance( + &self, + session: &mut BuildResolutionSession, + outcome: Option, + ) -> Result { + self.consume_probe(session, outcome)?; + loop { + if session.runtime_index < session.plan.runtime_dependencies.len() { + if let Some(step) = self.advance_runtime(session)? { + return Ok(step); + } + continue; + } + return self.advance_tool(session); + } + } + + fn consume_probe( + &self, + session: &mut BuildResolutionSession, + outcome: Option, + ) -> Result<(), BuildResolutionError> { + let Some(pending) = session.pending.take() else { + if outcome.is_some() { + return Err(BuildResolutionError::invalid_plan( + "a version outcome was supplied without a pending probe", + )); + } + return Ok(()); + }; + let outcome = outcome.ok_or_else(|| { + BuildResolutionError::invalid_plan("the pending version probe has no outcome") + })?; + match outcome { + VersionProbeOutcome::Accepted { version } => match pending.target { + PendingTarget::Runtime { id } => { + session.environment = pending.selection.environment; + session + .runtime_executables + .insert(id, pending.selection.executable); + session.runtime_index += 1; + session.runtime_candidate_index = 0; + } + PendingTarget::Tool => { + session.pending = Some(PendingProbe { + target: PendingTarget::ResolvedTool { version }, + selection: pending.selection, + minimum_version: pending.minimum_version, + }); + } + PendingTarget::ResolvedTool { .. } => { + return Err(BuildResolutionError::invalid_plan( + "a completed build-tool probe was resumed twice", + )); + } + }, + VersionProbeOutcome::Rejected { version } => { + let problem = match version { + Some(version) => format!( + "{} reported version {version}; {} or newer is required", + pending.selection.executable.display(), + pending.minimum_version + ), + None => format!( + "{} did not report a usable version; {} or newer is required", + pending.selection.executable.display(), + pending.minimum_version + ), + }; + if pending.selection.terminal { + return Err(BuildResolutionError::configured(problem)); + } + session.problems.push(problem); + } + VersionProbeOutcome::Failed { message } => { + let problem = format!( + "version probe for {} failed: {message}", + pending.selection.executable.display() + ); + if pending.selection.terminal { + return Err(BuildResolutionError::configured(problem)); + } + session.problems.push(problem); + } + } + Ok(()) + } + + fn advance_runtime( + &self, + session: &mut BuildResolutionSession, + ) -> Result, BuildResolutionError> { + let dependency = session.plan.runtime_dependencies[session.runtime_index].clone(); + let sources = self.runtime_sources(&dependency, &session.environment); + while session.runtime_candidate_index < sources.len() { + let source = sources[session.runtime_candidate_index].clone(); + session.runtime_candidate_index += 1; + let Some(mut selection) = self.resolve_source(session, source)? else { + continue; + }; + prepend_path(&mut selection.environment, &selection.executable)?; + if selection.executes_project_code { + session.environment = selection.environment; + session + .runtime_executables + .insert(dependency.id.clone(), selection.executable); + session.runtime_index += 1; + session.runtime_candidate_index = 0; + return Ok(None); + } + if let Some((arguments, minimum_version)) = dependency.probe() { + let probe = VersionProbePlan::new( + selection.clone(), + arguments, + Some(dependency.id.clone()), + minimum_version.clone(), + ); + session.pending = Some(PendingProbe { + target: PendingTarget::Runtime { + id: dependency.id.clone(), + }, + selection, + minimum_version, + }); + return Ok(Some(BuildResolutionStep::Probe(probe))); + } + session.environment = selection.environment; + session + .runtime_executables + .insert(dependency.id.clone(), selection.executable); + session.runtime_index += 1; + session.runtime_candidate_index = 0; + return Ok(None); + } + let name = dependency.display_name.as_deref().unwrap_or(&dependency.id); + Err(BuildResolutionError::unavailable( + format!("build runtime {name} is not available"), + session.configured_setting, + &session.problems, + )) + } + + fn advance_tool( + &self, + session: &mut BuildResolutionSession, + ) -> Result { + if let Some(PendingProbe { + target: PendingTarget::ResolvedTool { version }, + selection, + .. + }) = session.pending.take() + { + return Ok(BuildResolutionStep::Resolved(ResolvedBuildTool { + executable: selection.executable, + environment: selection.environment, + executes_project_code: selection.executes_project_code, + version, + runtime_executables: session.runtime_executables.clone(), + })); + } + while session.tool_candidate_index < session.plan.executables.len() { + let candidate = session.plan.executables[session.tool_candidate_index].clone(); + session.tool_candidate_index += 1; + let Some(mut selection) = self.resolve_candidate(session, candidate)? else { + continue; + }; + if selection.terminal { + prepend_path(&mut selection.environment, &selection.executable)?; + } + if selection.executes_project_code { + return Ok(BuildResolutionStep::Resolved(ResolvedBuildTool { + executable: selection.executable, + environment: selection.environment, + executes_project_code: true, + version: None, + runtime_executables: session.runtime_executables.clone(), + })); + } + if let Some((arguments, minimum_version)) = session.plan.probe() { + let probe = VersionProbePlan::new( + selection.clone(), + arguments, + None, + minimum_version.clone(), + ); + session.pending = Some(PendingProbe { + target: PendingTarget::Tool, + selection, + minimum_version, + }); + return Ok(BuildResolutionStep::Probe(probe)); + } + return Ok(BuildResolutionStep::Resolved(ResolvedBuildTool { + executable: selection.executable, + environment: selection.environment, + executes_project_code: false, + version: None, + runtime_executables: session.runtime_executables.clone(), + })); + } + Err(BuildResolutionError::unavailable( + format!( + "build tool {} is not available", + session.plan.build_system_id + ), + session.configured_setting, + &session.problems, + )) + } + + fn runtime_sources( + &self, + dependency: &RuntimeDependencyPlan, + environment: &BTreeMap, + ) -> Vec { + if !dependency.host_managed { + return dependency + .executables + .iter() + .cloned() + .map(ResolutionSource::Candidate) + .collect(); + } + let mut sources = Vec::new(); + if let Some(input) = self.runtimes.get(&dependency.id) { + sources.push(ResolutionSource::Direct { + executable: input.executable.clone(), + environment: input.environment.clone(), + terminal: input.explicit, + }); + if input.explicit { + return sources; + } + } + if dependency.id.eq_ignore_ascii_case("java") { + if let Some(home) = environment_value(environment, "JAVA_HOME") { + sources.push(ResolutionSource::Direct { + executable: PathBuf::from(home).join("bin").join("java.exe"), + environment: BTreeMap::from([("JAVA_HOME".into(), home.to_string())]), + terminal: false, + }); + } + } + sources.push(ResolutionSource::Candidate(ExecutableCandidate::Command { + command: dependency.id.clone(), + })); + sources + } + + fn resolve_source( + &self, + session: &BuildResolutionSession, + source: ResolutionSource, + ) -> Result, BuildResolutionError> { + match source { + ResolutionSource::Candidate(candidate) => self.resolve_candidate(session, candidate), + ResolutionSource::Direct { + executable, + environment, + terminal, + } => { + let mut selected_environment = session.environment.clone(); + for (key, value) in environment { + environment_set(&mut selected_environment, key, value); + } + let discovered = discovery_path(&session.discovery_root, &executable); + if discovered + .as_ref() + .is_none_or(|executable| !is_spawnable_windows_file(executable)) + { + if terminal { + return Err(BuildResolutionError::configured(format!( + "configured runtime {} is not a Windows executable file", + executable.display() + ))); + } + return Ok(None); + } + Ok(Some(SelectedCandidate { + executable: discovered.expect("spawnable discovered executable"), + environment: selected_environment, + executes_project_code: false, + terminal, + })) + } + } + } + + fn resolve_candidate( + &self, + session: &BuildResolutionSession, + candidate: ExecutableCandidate, + ) -> Result, BuildResolutionError> { + let environment = session.environment.clone(); + let found = match candidate { + ExecutableCandidate::ProjectFile { path, requires, .. } => { + for required in requires { + if project_entry(session, &required, false)?.is_none() { + return Ok(None); + } + } + project_entry(session, &path, true)?.map(|executable| SelectedCandidate { + executable, + environment, + executes_project_code: true, + terminal: false, + }) + } + ExecutableCandidate::Environment { variable, path } => { + if !session + .plan + .environment_inputs + .iter() + .any(|input| input.eq_ignore_ascii_case(&variable)) + { + return Ok(None); + } + let Some(base) = environment_value(&environment, &variable) else { + return Ok(None); + }; + let base = PathBuf::from(base); + let relative = plan_relative_path(&path, "environment executable")?; + let Some(executable) = + discovery_path(&session.discovery_root, &base.join(relative)) + else { + return Ok(None); + }; + is_spawnable_windows_file(&executable).then_some(SelectedCandidate { + executable, + environment, + executes_project_code: false, + terminal: false, + }) + } + ExecutableCandidate::Command { command } => { + let path = environment_value(&environment, "PATH") + .and_then(|path| { + let directories: Vec<_> = std::env::split_paths(path) + .filter_map(|directory| { + discovery_path(&session.discovery_root, &directory) + }) + .collect(); + // An empty join would become an implicit cwd entry in + // find_system_tool instead of an empty search list. + (!directories.is_empty()).then(|| std::env::join_paths(directories)) + }) + .transpose() + .map_err(|error| { + BuildResolutionError::invalid_plan(format!( + "build tool search path is invalid: {error}" + )) + })?; + super::super::language_tools::find_system_tool(path.as_deref(), &command).map( + |executable| SelectedCandidate { + executable, + environment, + executes_project_code: false, + terminal: false, + }, + ) + } + ExecutableCandidate::Runtime { runtime } => session + .runtime_executables + .get(&runtime) + .cloned() + .map(|executable| SelectedCandidate { + executable, + environment, + executes_project_code: false, + terminal: false, + }), + ExecutableCandidate::Setting { setting_id, path } => { + let Some(path) = path else { return Ok(None) }; + let executable = PathBuf::from(&path); + if !executable.is_absolute() || !is_spawnable_windows_file(&executable) { + return Err(BuildResolutionError::configured(format!( + "configured build tool {setting_id} is not an executable file: {path}" + ))); + } + Some(SelectedCandidate { + executable, + environment, + executes_project_code: false, + terminal: true, + }) + } + }; + Ok(found) + } +} + +/// Mutable continuation owned by one upper-layer build operation. +#[derive(Debug)] +pub(crate) struct BuildResolutionSession { + plan: InvocationPlan, + workspace_root: PathBuf, + instance_root: PathBuf, + /// Frozen host cwd for environment/PATH/runtime discovery inputs. + discovery_root: PathBuf, + environment: BTreeMap, + runtime_executables: BTreeMap, + runtime_index: usize, + runtime_candidate_index: usize, + tool_candidate_index: usize, + pending: Option, + problems: Vec, + configured_setting: bool, +} + +/// The caller either executes one bounded version probe or receives the tool. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BuildResolutionStep { + Probe(VersionProbePlan), + Resolved(ResolvedBuildTool), +} + +/// A process plan only; creating this value grants no execution permission. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VersionProbePlan { + pub(crate) executable: PathBuf, + pub(crate) arguments: Vec, + pub(crate) environment: BTreeMap, + pub(crate) runtime: Option, + pub(crate) minimum_version: String, + pub(crate) terminal: bool, +} + +impl VersionProbePlan { + fn new( + selection: SelectedCandidate, + arguments: Vec, + runtime: Option, + minimum_version: String, + ) -> Self { + Self { + executable: selection.executable, + arguments, + environment: selection.environment, + runtime, + minimum_version, + terminal: selection.terminal, + } + } + + /// Request body for Core's `build.checkToolVersion` after the caller has + /// captured combined stdout and stderr from this probe. + pub(crate) fn core_request(&self, output: impl Into) -> Value { + let mut request = json!({ "output": output.into() }); + if let Some(runtime) = &self.runtime { + request["runtime"] = json!(runtime); + } + request + } +} + +/// Core's version judgment, or a platform probe failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum VersionProbeOutcome { + Accepted { version: Option }, + Rejected { version: Option }, + Failed { message: String }, +} + +impl VersionProbeOutcome { + /// Parses the `version` value returned by `build.checkToolVersion`. + pub(crate) fn from_core(value: &Value) -> Result { + let value = value.get("version").unwrap_or(value); + let satisfied = value + .get("satisfied") + .and_then(Value::as_bool) + .ok_or_else(|| { + BuildResolutionError::invalid_plan( + "build.checkToolVersion returned no satisfied boolean", + ) + })?; + let version = value + .get("version") + .and_then(Value::as_str) + .map(str::to_string); + Ok(if satisfied { + Self::Accepted { version } + } else { + Self::Rejected { version } + }) + } +} + +/// Selected executable and the exact environment its probe must also use. +/// +/// `executes_project_code` remains an authorization input. Resolution alone +/// never grants workspace trust or permission to start the process. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResolvedBuildTool { + pub(crate) executable: PathBuf, + pub(crate) environment: BTreeMap, + pub(crate) executes_project_code: bool, + pub(crate) version: Option, + pub(crate) runtime_executables: BTreeMap, +} + +/// Stable host-side category plus a diagnostic message for the upper layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BuildResolutionError { + code: &'static str, + message: String, + terminal: bool, +} + +impl BuildResolutionError { + pub(crate) fn code(&self) -> &'static str { + self.code + } + + pub(crate) fn is_terminal(&self) -> bool { + self.terminal + } + + fn invalid_plan(message: impl Into) -> Self { + Self { + code: "plugin.build.invalidPlan", + message: message.into(), + terminal: true, + } + } + + fn project_escape(message: impl Into) -> Self { + Self { + code: "plugin.build.projectPathEscape", + message: message.into(), + terminal: true, + } + } + + fn configured(message: impl Into) -> Self { + Self { + code: "plugin.build.configuredToolUnavailable", + message: message.into(), + terminal: true, + } + } + + fn unavailable(message: String, terminal: bool, problems: &[String]) -> Self { + let message = if problems.is_empty() { + message + } else { + format!("{message}. {}", problems.join(". ")) + }; + Self { + code: if terminal { + "plugin.build.configuredToolUnavailable" + } else { + "plugin.build.toolUnavailable" + }, + message, + terminal, + } + } +} + +impl fmt::Display for BuildResolutionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for BuildResolutionError {} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct InvocationPlan { + #[serde(rename = "buildSystemID")] + build_system_id: String, + root: String, + executables: Vec, + #[serde(default)] + environment_inputs: Vec, + #[serde(default)] + runtime_dependencies: Vec, + version: Option, + minimum_version: Option, +} + +impl InvocationPlan { + fn probe(&self) -> Option<(Vec, String)> { + Some(( + self.version.as_ref()?.arguments.clone(), + self.minimum_version.clone()?, + )) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RuntimeDependencyPlan { + id: String, + display_name: Option, + host_managed: bool, + minimum_version: Option, + #[serde(default)] + executables: Vec, + version: Option, +} + +impl RuntimeDependencyPlan { + fn probe(&self) -> Option<(Vec, String)> { + Some(( + self.version.as_ref()?.arguments.clone(), + self.minimum_version.clone()?, + )) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct VersionProbe { + arguments: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +enum ExecutableCandidate { + ProjectFile { + path: String, + #[serde(default)] + requires: Vec, + #[serde(default, rename = "executesProjectCode")] + _executes_project_code: bool, + }, + Environment { + variable: String, + path: String, + }, + Command { + command: String, + }, + Runtime { + runtime: String, + }, + Setting { + #[serde(rename = "settingID")] + setting_id: String, + path: Option, + }, +} + +impl ExecutableCandidate { + fn has_configured_setting(&self) -> bool { + matches!(self, Self::Setting { path: Some(_), .. }) + } +} + +#[derive(Debug, Clone)] +enum ResolutionSource { + Candidate(ExecutableCandidate), + Direct { + executable: PathBuf, + environment: BTreeMap, + terminal: bool, + }, +} + +#[derive(Debug, Clone)] +struct SelectedCandidate { + executable: PathBuf, + environment: BTreeMap, + executes_project_code: bool, + terminal: bool, +} + +#[derive(Debug)] +struct PendingProbe { + target: PendingTarget, + selection: SelectedCandidate, + minimum_version: String, +} + +#[derive(Debug)] +enum PendingTarget { + Runtime { id: String }, + Tool, + ResolvedTool { version: Option }, +} + +fn absolute_existing_directory(path: &Path, label: &str) -> Result { + if !path.is_absolute() { + return Err(BuildResolutionError::invalid_plan(format!( + "{label} must be absolute: {}", + path.display() + ))); + } + let metadata = std::fs::symlink_metadata(path).map_err(|error| { + BuildResolutionError::invalid_plan(format!( + "{label} {} is unreadable: {error}", + path.display() + )) + })?; + if is_reparse_or_link(&metadata) || !metadata.is_dir() { + return Err(BuildResolutionError::project_escape(format!( + "{label} {} must be a real directory", + path.display() + ))); + } + path.canonicalize().map_err(|error| { + BuildResolutionError::invalid_plan(format!( + "{label} {} could not be resolved: {error}", + path.display() + )) + }) +} + +/// Freezes discovered paths before testing or returning them. Drive-relative +/// Windows paths are ambiguous even with a host cwd, so they are not candidates. +fn discovery_path(host_directory: &Path, path: &Path) -> Option { + if path.is_absolute() { + return Some(path.to_path_buf()); + } + if path + .components() + .any(|component| matches!(component, Component::Prefix(_) | Component::RootDir)) + { + return None; + } + Some(host_directory.join(path)) +} + +fn plan_relative_path(value: &str, label: &str) -> Result { + let path = Path::new(value); + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::Prefix(_) | Component::RootDir + ) + }) + { + return Err(BuildResolutionError::invalid_plan(format!( + "{label} must be workspace-relative: {value}" + ))); + } + Ok(value + .split(['/', '\\']) + .filter(|part| !part.is_empty() && *part != ".") + .fold(PathBuf::new(), |path, part| path.join(part))) +} + +fn project_entry( + session: &BuildResolutionSession, + relative: &str, + executable: bool, +) -> Result, BuildResolutionError> { + let relative = plan_relative_path(relative, "project executable")?; + let path = session.workspace_root.join(relative); + if !path.starts_with(&session.instance_root) { + return Err(BuildResolutionError::project_escape(format!( + "project executable {} is outside instance root {}", + path.display(), + session.instance_root.display() + ))); + } + if !reject_reparse_components(&session.workspace_root, &path)? { + return Ok(None); + } + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(BuildResolutionError::invalid_plan(format!( + "project entry {} is unreadable: {error}", + path.display() + ))) + } + }; + if is_reparse_or_link(&metadata) || !metadata.is_file() { + return Ok(None); + } + let canonical = path.canonicalize().map_err(|error| { + BuildResolutionError::invalid_plan(format!( + "project entry {} could not be resolved: {error}", + path.display() + )) + })?; + if !path_is_within(&canonical, &session.instance_root) { + return Err(BuildResolutionError::project_escape(format!( + "project entry {} resolves outside instance root {}", + path.display(), + session.instance_root.display() + ))); + } + if executable && !has_windows_executable_type(&path) { + return Ok(None); + } + Ok(Some(path)) +} + +/// Returns `false` only when a path component does not exist. Other metadata +/// failures stay visible instead of being mistaken for a missing candidate. +fn reject_reparse_components(root: &Path, target: &Path) -> Result { + let relative = target.strip_prefix(root).map_err(|_| { + BuildResolutionError::project_escape(format!( + "{} is outside workspace {}", + target.display(), + root.display() + )) + })?; + let mut current = root.to_path_buf(); + for component in relative.components() { + current.push(component.as_os_str()); + let metadata = match std::fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(BuildResolutionError::invalid_plan(format!( + "project path {} is unreadable: {error}", + current.display() + ))) + } + }; + if is_reparse_or_link(&metadata) { + return Err(BuildResolutionError::project_escape(format!( + "project path {} crosses a reparse point", + current.display() + ))); + } + } + Ok(true) +} + +fn is_reparse_or_link(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + } + #[cfg(not(windows))] + { + metadata.file_type().is_symlink() + } +} + +fn path_is_within(path: &Path, root: &Path) -> bool { + #[cfg(windows)] + { + let path = path.components().collect::>(); + let root = root.components().collect::>(); + path.len() >= root.len() + && path.iter().zip(root.iter()).all(|(left, right)| { + left.as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case(&right.as_os_str().to_string_lossy()) + }) + } + #[cfg(not(windows))] + { + path.starts_with(root) + } +} + +fn is_spawnable_windows_file(path: &Path) -> bool { + let Ok(metadata) = std::fs::symlink_metadata(path) else { + return false; + }; + !is_reparse_or_link(&metadata) && metadata.is_file() && has_windows_executable_type(path) +} + +fn has_windows_executable_type(path: &Path) -> bool { + path.extension() + .and_then(OsStr::to_str) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("exe") + || extension.eq_ignore_ascii_case("cmd") + || extension.eq_ignore_ascii_case("bat") + }) +} + +fn environment_value<'a>(environment: &'a BTreeMap, key: &str) -> Option<&'a str> { + environment + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key)) + .map(|(_, value)| value.as_str()) +} + +fn environment_set(environment: &mut BTreeMap, key: String, value: String) { + let aliases = environment + .keys() + .filter(|candidate| candidate.eq_ignore_ascii_case(&key)) + .cloned() + .collect::>(); + for alias in aliases { + environment.remove(&alias); + } + environment.insert(key, value); +} + +fn prepend_path( + environment: &mut BTreeMap, + executable: &Path, +) -> Result<(), BuildResolutionError> { + let directory = executable.parent().ok_or_else(|| { + BuildResolutionError::invalid_plan(format!( + "executable {} has no parent directory", + executable.display() + )) + })?; + let existing = environment_value(environment, "PATH").unwrap_or_default(); + let mut paths = vec![directory.to_path_buf()]; + paths.extend(std::env::split_paths(OsStr::new(existing))); + let joined = std::env::join_paths(paths).map_err(|error| { + BuildResolutionError::invalid_plan(format!("could not construct PATH: {error}")) + })?; + environment_set( + environment, + "PATH".into(), + joined.to_string_lossy().into_owned(), + ); + Ok(()) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build_resolver_tests.rs b/windows/tauri/src-tauri/src/plugin_host/build_resolver_tests.rs new file mode 100644 index 0000000000..273a58b6a1 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build_resolver_tests.rs @@ -0,0 +1,692 @@ +use super::build_resolver::{ + BuildResolutionStep, RuntimeExecutableInput, VersionProbeOutcome, VersionProbePlan, + WindowsBuildToolResolver, +}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); + +struct Fixture { + root: PathBuf, +} + +impl Fixture { + fn new(label: &str) -> Self { + Self::under(std::env::temp_dir(), label) + } + + fn in_host_directory(label: &str) -> Self { + Self::under( + std::env::current_dir() + .expect("host directory") + .join(".artifacts"), + label, + ) + } + + fn under(parent: PathBuf, label: &str) -> Self { + let id = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed); + let root = parent.join(format!( + "lithe-build-resolver-{label}-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("fixture root"); + Self { root } + } + + fn directory(&self, relative: impl AsRef) -> PathBuf { + let path = self.root.join(relative); + std::fs::create_dir_all(&path).expect("fixture directory"); + path + } + + fn file(&self, relative: impl AsRef) -> PathBuf { + let path = self.root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("fixture file parent"); + } + std::fs::write(&path, b"placeholder").expect("fixture file"); + path + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn contract_fixture(name: &str) -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../shared/contracts/plugins/v1/fixtures") + .join(name); + serde_json::from_str(&std::fs::read_to_string(path).expect("contract fixture")) + .expect("fixture JSON") +} + +fn invocation_plan(manifest: &Value, build_system_id: &str, root: &str, settings: Value) -> Value { + let result = super::scan::core_execute( + "build.invocationPlan", + json!({ + "manifest": manifest, + "buildSystemID": build_system_id, + "request": {"root": root, "platform": "windows", "settings": settings}, + }), + ) + .expect("Core invocation plan"); + assert_eq!(result["status"], "ok", "{result}"); + result["invocation"].clone() +} + +fn planned_fixture_case(name: &str) -> (Value, String, Value) { + let cases = contract_fixture("build-plans.json"); + let case = cases["cases"] + .as_array() + .expect("cases") + .iter() + .find(|case| case["name"] == name) + .unwrap_or_else(|| panic!("missing build plan case {name}")); + let manifest = contract_fixture(case["plugin"].as_str().expect("plugin fixture")); + let build_system = case["buildSystemID"] + .as_str() + .expect("build system") + .to_string(); + let mut request = case["request"].clone(); + request["platform"] = json!("windows"); + let result = super::scan::core_execute( + "build.invocationPlan", + json!({ + "manifest": manifest, + "buildSystemID": build_system, + "request": request, + }), + ) + .expect("Core fixture plan"); + assert_eq!(result["status"], "ok", "{result}"); + (manifest, build_system, result["invocation"].clone()) +} + +fn check_version( + manifest: &Value, + build_system: &str, + probe: &VersionProbePlan, + output: &str, +) -> VersionProbeOutcome { + let result = super::scan::core_execute( + "build.checkToolVersion", + json!({ + "manifest": manifest, + "buildSystemID": build_system, + "request": probe.core_request(output), + }), + ) + .expect("Core version check"); + assert_eq!(result["status"], "ok", "{result}"); + VersionProbeOutcome::from_core(&result).expect("version outcome") +} + +fn path_environment(path: &Path) -> BTreeMap { + BTreeMap::from([( + "PATH".to_string(), + std::env::join_paths([path]) + .expect("fixture PATH") + .to_string_lossy() + .into_owned(), + )]) +} + +fn first_path(environment: &BTreeMap) -> PathBuf { + let value = environment + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("PATH")) + .expect("PATH") + .1; + std::env::split_paths(value) + .next() + .expect("first PATH entry") +} + +fn expect_resolved(step: BuildResolutionStep) -> super::build_resolver::ResolvedBuildTool { + match step { + BuildResolutionStep::Resolved(tool) => tool, + BuildResolutionStep::Probe(probe) => { + panic!("unexpected probe for {}", probe.executable.display()) + } + } +} + +fn expect_probe(step: BuildResolutionStep) -> VersionProbePlan { + match step { + BuildResolutionStep::Probe(probe) => probe, + BuildResolutionStep::Resolved(tool) => { + panic!("unexpected resolved tool {}", tool.executable.display()) + } + } +} + +#[test] +fn configured_tool_with_spaces_wins_and_owns_its_path_environment() { + let fixture = Fixture::new("configured-spaces"); + let configured = fixture.file("custom tools/fixture-build.exe"); + let fallback = fixture.directory("fallback"); + fixture.file("fallback/fixture-build.cmd"); + let manifest = contract_fixture("build-tool-path.plugin.json"); + let plan = invocation_plan( + &manifest, + "fixture", + ".", + json!({"toolPath": configured.to_string_lossy()}), + ); + let resolver = + WindowsBuildToolResolver::with_environment(path_environment(&fallback), BTreeMap::new()); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let resolved = expect_resolved(resolver.advance(&mut session, None).expect("resolved")); + + assert_eq!(resolved.executable, configured); + assert_eq!( + first_path(&resolved.environment), + configured.parent().unwrap() + ); + assert!(!resolved.executes_project_code); +} + +#[test] +fn configured_missing_or_directory_is_terminal_and_never_falls_back() { + for failure in ["missing", "directory"] { + let fixture = Fixture::new(failure); + let configured = fixture.root.join("configured tool.exe"); + if failure == "directory" { + std::fs::create_dir_all(&configured).expect("configured directory"); + } + let fallback = fixture.directory("fallback"); + fixture.file("fallback/fixture-build.exe"); + let manifest = contract_fixture("build-tool-path.plugin.json"); + let plan = invocation_plan( + &manifest, + "fixture", + ".", + json!({"toolPath": configured.to_string_lossy()}), + ); + let resolver = WindowsBuildToolResolver::with_environment( + path_environment(&fallback), + BTreeMap::new(), + ); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let error = resolver + .advance(&mut session, None) + .expect_err("configured failure must terminate"); + assert_eq!(error.code(), "plugin.build.configuredToolUnavailable"); + assert!(error.is_terminal()); + assert!(error.to_string().contains("toolPath")); + } +} + +#[test] +fn configured_tool_version_rejection_and_probe_failure_are_terminal() { + let fixture = Fixture::new("configured-version"); + let configured = fixture.file("configured/build.exe"); + let path = fixture.directory("path"); + fixture.file("path/fixture-build.exe"); + let mut manifest = contract_fixture("build-tool-path.plugin.json"); + manifest["buildSystems"][0]["invocation"]["minimumVersion"] = json!("2"); + manifest["buildSystems"][0]["invocation"]["version"] = json!({ + "arguments":["--version"], "pattern":"build (\\d+(?:\\.\\d+)*)" + }); + let plan = invocation_plan( + &manifest, + "fixture", + ".", + json!({"toolPath": configured.to_string_lossy()}), + ); + let resolver = + WindowsBuildToolResolver::with_environment(path_environment(&path), BTreeMap::new()); + + for failed in [false, true] { + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let probe = expect_probe( + resolver + .advance(&mut session, None) + .expect("configured probe"), + ); + assert_eq!(probe.executable, configured); + assert!(probe.terminal); + assert_eq!(first_path(&probe.environment), configured.parent().unwrap()); + let outcome = if failed { + VersionProbeOutcome::Failed { + message: "controlled probe failure".into(), + } + } else { + check_version(&manifest, "fixture", &probe, "build 1.0") + }; + let error = resolver + .advance(&mut session, Some(outcome)) + .expect_err("configured version failure must not discover another tool"); + assert_eq!(error.code(), "plugin.build.configuredToolUnavailable"); + assert!(error.is_terminal()); + } +} + +#[test] +fn unset_empty_and_reset_settings_all_resume_core_ordered_discovery() { + let fixture = Fixture::new("empty-setting"); + let path = fixture.directory("path"); + let fallback = fixture.file("path/fixture-build.bat"); + let manifest = contract_fixture("build-tool-path.plugin.json"); + let resolver = + WindowsBuildToolResolver::with_environment(path_environment(&path), BTreeMap::new()); + + for settings in [json!({}), json!({"toolPath": ""}), json!({})] { + let plan = invocation_plan(&manifest, "fixture", ".", settings); + assert_eq!(plan["executables"][0]["path"], Value::Null); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let resolved = expect_resolved(resolver.advance(&mut session, None).expect("resolved")); + assert_eq!(resolved.executable, fallback); + } +} + +#[test] +fn published_build_v2_executable_setting_uses_the_same_terminal_candidate_path() { + let fixture = Fixture::new("legacy-setting"); + let configured = fixture.file("legacy tools/build.exe"); + let fallback = fixture.directory("path"); + fixture.file("path/fixture-build.exe"); + let mut manifest = contract_fixture("build-tool-path.plugin.json"); + manifest["requires"] + .as_object_mut() + .expect("requires") + .remove("executableResolution"); + manifest["requires"]["buildSystems"] = json!(2); + manifest["buildSystems"][0]["invocation"]["executables"] + .as_array_mut() + .expect("executables") + .remove(0); + manifest["buildSystems"][0]["invocation"]["executableSetting"] = json!("toolPath"); + + let plan = invocation_plan( + &manifest, + "fixture", + ".", + json!({"toolPath": configured.to_string_lossy()}), + ); + assert_eq!(plan["executables"][0]["kind"], "setting"); + let resolver = + WindowsBuildToolResolver::with_environment(path_environment(&fallback), BTreeMap::new()); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let resolved = expect_resolved(resolver.advance(&mut session, None).expect("resolved")); + assert_eq!(resolved.executable, configured); +} + +#[test] +fn build_plans_gradle_order_selects_the_project_wrapper_without_probing_it() { + let fixture = Fixture::new("gradle-wrapper"); + let wrapper = fixture.file("gradlew.bat"); + fixture.file("gradle/wrapper/gradle-wrapper.properties"); + let java = fixture.file("jdk/bin/java.exe"); + let gradle_home = fixture.directory("gradle-home"); + fixture.file("gradle-home/bin/gradle.bat"); + let path = fixture.directory("path"); + fixture.file("path/gradle.exe"); + let (manifest, build_system, plan) = + planned_fixture_case("gradle prefers the batch wrapper on windows"); + let mut environment = path_environment(&path); + environment.insert( + "GRADLE_HOME".into(), + gradle_home.to_string_lossy().into_owned(), + ); + let resolver = WindowsBuildToolResolver::with_environment( + environment, + BTreeMap::from([( + "java".into(), + RuntimeExecutableInput::discovered(java.clone()), + )]), + ); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let probe = expect_probe(resolver.advance(&mut session, None).expect("runtime probe")); + assert_eq!(probe.executable, java); + assert_eq!(probe.arguments, ["-version"]); + let outcome = check_version(&manifest, &build_system, &probe, "java version \"17.0.9\""); + let resolved = expect_resolved( + resolver + .advance(&mut session, Some(outcome)) + .expect("wrapper resolution"), + ); + assert_eq!( + resolved.executable, + wrapper.canonicalize().expect("wrapper path") + ); + assert!(resolved.executes_project_code); + assert_eq!(resolved.runtime_executables["java"], java); +} + +#[test] +fn path_lookup_uses_windows_launcher_extensions_and_ignores_extensionless_files() { + let fixture = Fixture::new("pathext"); + fixture.directory("crates/app"); + let path = fixture.directory("path"); + fixture.file("path/cargo"); + let command = fixture.file("path/cargo.cmd"); + let (manifest, build_system, plan) = + planned_fixture_case("cargo invocation is the cargo command"); + let resolver = + WindowsBuildToolResolver::with_environment(path_environment(&path), BTreeMap::new()); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let probe = expect_probe(resolver.advance(&mut session, None).expect("probe")); + assert_eq!(probe.executable, command); + let outcome = check_version(&manifest, &build_system, &probe, "cargo 1.80.1"); + let resolved = expect_resolved( + resolver + .advance(&mut session, Some(outcome)) + .expect("resolved"), + ); + assert_eq!(resolved.version.as_deref(), Some("1.80.1")); +} + +#[test] +fn environment_candidates_read_only_declared_inputs() { + let fixture = Fixture::new("environment-whitelist"); + let environment_home = fixture.directory("environment-home"); + fixture.file("environment-home/bin/build.exe"); + let fallback_path = fixture.directory("path"); + let fallback = fixture.file("path/fallback.exe"); + let plan = json!({ + "buildSystemID": "fixture", + "root": ".", + "executables": [ + {"kind":"environment", "variable":"SECRET_HOME", "path":"bin/build.exe"}, + {"kind":"command", "command":"fallback"} + ], + "environmentInputs": [], + "runtimeDependencies": [], + "version": null, + "minimumVersion": null + }); + let mut environment = path_environment(&fallback_path); + environment.insert( + "SECRET_HOME".into(), + environment_home.to_string_lossy().into_owned(), + ); + let resolver = WindowsBuildToolResolver::with_environment(environment, BTreeMap::new()); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let resolved = expect_resolved(resolver.advance(&mut session, None).expect("resolved")); + assert_eq!(resolved.executable, fallback); +} + +fn assert_relative_discovery_is_frozen(kind: &str) { + // All paths really exist relative to the host cwd, while execution will + // use a different workspace cwd. No test mutates the process-wide cwd. + let host_directory = std::env::current_dir().expect("host directory"); + let fixture = Fixture::in_host_directory(kind); + let executable = fixture.file("tools/bin/build.exe"); + let relative_home = fixture + .root + .join("tools") + .strip_prefix(&host_directory) + .expect("fixture belongs to host directory") + .to_path_buf(); + let relative_executable = relative_home.join("bin/build.exe"); + let mut environment = path_environment(&relative_home.join("bin")); + environment.insert( + "CUSTOM_HOME".into(), + relative_home.to_string_lossy().into_owned(), + ); + let candidate = match kind { + "environment" => { + json!({"kind":"environment", "variable":"CUSTOM_HOME", "path":"bin/build.exe"}) + } + "path" => json!({"kind":"command", "command":"build"}), + "host-runtime" => json!({"kind":"runtime", "runtime":"custom"}), + _ => panic!("unexpected relative discovery kind {kind}"), + }; + let plan = json!({ + "buildSystemID":"fixture", "root":".", "executables":[candidate], + "environmentInputs":["CUSTOM_HOME"], + "runtimeDependencies": if kind == "host-runtime" { json!([{ + "id":"custom", "hostManaged":true, "executables":[] + }]) } else { json!([]) }, + "version":{"arguments":["--version"]}, "minimumVersion":"1" + }); + let resolver = WindowsBuildToolResolver::with_environment( + environment, + BTreeMap::from([( + "custom".into(), + RuntimeExecutableInput::discovered(relative_executable), + )]), + ); + let workspace = fixture.directory("workspace"); + let mut session = resolver.start(&plan, &workspace).expect("session"); + let probe = expect_probe(resolver.advance(&mut session, None).expect("probe")); + assert!( + probe.executable.is_absolute(), + "probe must not change identity with cwd" + ); + assert_eq!(probe.executable, executable); + let resolved = expect_resolved( + resolver + .advance( + &mut session, + Some(VersionProbeOutcome::Accepted { + version: Some("1".into()), + }), + ) + .expect("resolved"), + ); + assert_eq!(resolved.executable, executable); + if kind == "host-runtime" { + assert_eq!(resolved.runtime_executables["custom"], executable); + assert_eq!( + first_path(&resolved.environment), + executable.parent().expect("tool directory") + ); + } +} + +#[test] +fn relative_environment_home_is_frozen_before_the_probe() { + assert_relative_discovery_is_frozen("environment"); +} + +#[test] +fn relative_path_entry_is_frozen_before_the_probe() { + assert_relative_discovery_is_frozen("path"); +} + +#[test] +fn relative_host_runtime_is_frozen_before_the_probe() { + assert_relative_discovery_is_frozen("host-runtime"); +} + +#[test] +fn rejected_runtime_candidate_does_not_leak_its_path_into_the_next_candidate() { + let fixture = Fixture::new("runtime-isolation"); + let old_home = fixture.directory("old-runtime"); + let old = fixture.file("old-runtime/old.exe"); + let path = fixture.directory("path"); + let fallback = fixture.file("path/custom.exe"); + let mut manifest = contract_fixture("build-tool-path.plugin.json"); + manifest["buildSystems"][0]["invocation"] = json!({ + "executables": [{"kind":"runtime", "runtime":"custom"}], + "environmentInputs": ["CUSTOM_HOME"], + "runtimeDependencies": [{ + "id":"custom", + "minimumVersion":"2", + "executables":[ + {"kind":"environment", "variable":"CUSTOM_HOME", "path":"old.exe"}, + {"kind":"command", "command":"custom"} + ], + "version":{"arguments":["--version"], "pattern":"custom (\\d+(?:\\.\\d+)*)"} + }] + }); + manifest["requires"] + .as_object_mut() + .expect("requires") + .remove("executableResolution"); + let plan = invocation_plan(&manifest, "fixture", ".", json!({})); + let mut environment = path_environment(&path); + environment.insert( + "CUSTOM_HOME".into(), + old_home.to_string_lossy().into_owned(), + ); + let resolver = WindowsBuildToolResolver::with_environment(environment, BTreeMap::new()); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + + let old_probe = expect_probe(resolver.advance(&mut session, None).expect("old probe")); + assert_eq!(old_probe.executable, old); + assert_eq!(first_path(&old_probe.environment), old_home); + let rejected = check_version(&manifest, "fixture", &old_probe, "custom 1.0"); + let fallback_probe = expect_probe( + resolver + .advance(&mut session, Some(rejected)) + .expect("fallback probe"), + ); + assert_eq!(fallback_probe.executable, fallback); + assert_eq!(first_path(&fallback_probe.environment), path); + assert!( + !std::env::split_paths(fallback_probe.environment.get("PATH").expect("PATH")) + .any(|entry| entry == old_home) + ); + + let accepted = check_version(&manifest, "fixture", &fallback_probe, "custom 2.3"); + let resolved = expect_resolved( + resolver + .advance(&mut session, Some(accepted)) + .expect("resolved runtime tool"), + ); + assert_eq!(resolved.executable, fallback); + assert_eq!(resolved.runtime_executables["custom"], fallback); +} + +#[test] +fn host_runtime_probe_failure_is_isolated_and_explicit_selection_is_terminal() { + let fixture = Fixture::new("host-runtime-failure"); + let selected = fixture.file("selected/bin/java.exe"); + let path = fixture.directory("path"); + let fallback = fixture.file("path/java.exe"); + let (_, _, plan) = planned_fixture_case("gradle prefers the batch wrapper on windows"); + + for explicit in [false, true] { + let resolver = WindowsBuildToolResolver::with_environment( + path_environment(&path), + BTreeMap::from([( + "java".into(), + RuntimeExecutableInput { + executable: selected.clone(), + environment: BTreeMap::from([("JAVA_HOME".into(), "selected-home".into())]), + explicit, + }, + )]), + ); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let selected_probe = expect_probe(resolver.advance(&mut session, None).expect("probe")); + assert_eq!(selected_probe.executable, selected); + assert_eq!(selected_probe.environment["JAVA_HOME"], "selected-home"); + let next = resolver.advance( + &mut session, + Some(VersionProbeOutcome::Failed { + message: "controlled runtime failure".into(), + }), + ); + if explicit { + let error = next.expect_err("explicit runtime must not fall back"); + assert_eq!(error.code(), "plugin.build.configuredToolUnavailable"); + assert!(error.is_terminal()); + } else { + let probe = expect_probe(next.expect("discovered runtime fallback")); + assert_eq!(probe.executable, fallback); + assert_eq!(first_path(&probe.environment), path); + assert!(!probe.environment.contains_key("JAVA_HOME")); + } + } +} + +#[test] +fn project_candidates_are_scoped_to_the_selected_instance_root() { + let fixture = Fixture::new("instance-roots"); + let one = fixture.file("one/wrapper.cmd"); + let two = fixture.file("two/wrapper.cmd"); + let mut manifest = contract_fixture("build-tool-path.plugin.json"); + manifest["buildSystems"][0]["invocation"] = json!({ + "executables": [{"kind":"projectFile", "path":"wrapper.cmd"}] + }); + manifest["requires"] + .as_object_mut() + .expect("requires") + .remove("executableResolution"); + let resolver = WindowsBuildToolResolver::with_environment(BTreeMap::new(), BTreeMap::new()); + for (root, expected) in [("one", one), ("two", two)] { + let plan = invocation_plan(&manifest, "fixture", root, json!({})); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let resolved = expect_resolved(resolver.advance(&mut session, None).expect("resolved")); + assert_eq!( + resolved.executable, + expected.canonicalize().expect("project executable") + ); + assert!(resolved.executes_project_code); + } + + let mut escaped = invocation_plan(&manifest, "fixture", "one", json!({})); + escaped["executables"][0]["path"] = json!("two/wrapper.cmd"); + let mut session = resolver.start(&escaped, &fixture.root).expect("session"); + let error = resolver + .advance(&mut session, None) + .expect_err("sibling instance path must fail"); + assert_eq!(error.code(), "plugin.build.projectPathEscape"); +} + +#[test] +fn project_candidate_rejects_a_junction_or_symlink_escape() { + let fixture = Fixture::new("project link"); + fixture.directory("instance"); + let outside = fixture.directory("outside"); + fixture.file("outside/wrapper.cmd"); + let link = fixture.root.join("instance").join("linked"); + create_directory_link(&outside, &link); + let mut manifest = contract_fixture("build-tool-path.plugin.json"); + manifest["buildSystems"][0]["invocation"] = json!({ + "executables": [{"kind":"projectFile", "path":"linked/wrapper.cmd"}] + }); + manifest["requires"] + .as_object_mut() + .expect("requires") + .remove("executableResolution"); + let plan = invocation_plan(&manifest, "fixture", "instance", json!({})); + let resolver = WindowsBuildToolResolver::with_environment(BTreeMap::new(), BTreeMap::new()); + let mut session = resolver.start(&plan, &fixture.root).expect("session"); + let error = resolver + .advance(&mut session, None) + .expect_err("link escape must fail"); + assert_eq!(error.code(), "plugin.build.projectPathEscape"); +} + +fn create_directory_link(target: &Path, link: &Path) { + #[cfg(windows)] + { + // Junctions exercise the reparse boundary without Developer Mode or + // admin privilege. Native separators keep mklink from reading a slash + // in a fixture path as a command option; Command owns argument quoting. + let target = target.components().collect::(); + let link = link.components().collect::(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut command = std::process::Command::new("cmd"); + command + .args(["/D", "/C", "mklink", "/J"]) + .arg(&link) + .arg(&target); + let outcome = lithe_git_host::run( + &mut command, + None, + || std::time::Instant::now() >= deadline, + || {}, + |_, _| {}, + ); + assert!( + outcome.failure.is_none() && outcome.status.is_some_and(|status| status.success()), + "junction creation failed: {}", + String::from_utf8_lossy(&outcome.stderr) + ); + } + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).expect("directory symlink"); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build_tests.rs b/windows/tauri/src-tauri/src/plugin_host/build_tests.rs new file mode 100644 index 0000000000..0de137c0d1 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build_tests.rs @@ -0,0 +1,1391 @@ +//! Tests for the M4-A build registry: detection, static models, option +//! persistence, and sync fingerprints against the real Core, plus the +//! failure and staleness paths. No test starts a process: this batch is +//! read-only, so every scenario is filesystem- and envelope-level only. + +use super::build_registry::{ + BuildCoreExecutor, BuildOptionsPersistence, BuildPackageInput, BuildPackageSource, + BuildRegistry, StoredBuildOptions, +}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; + +const FIXTURES_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures" +); + +fn static_fixture() -> Value { + let text = + std::fs::read_to_string(Path::new(FIXTURES_DIR).join("build-static-file.plugin.json")) + .expect("static fixture exists"); + serde_json::from_str(&text).expect("static fixture is JSON") +} + +/// Deterministic temp workspace that removes itself on drop; the optional +/// space in the name also exercises paths with spaces through Core. +struct TempWorkspace { + root: PathBuf, +} + +impl TempWorkspace { + fn new(name: &str, spaced: bool) -> Self { + let mut root = std::env::temp_dir(); + root.push(format!("lithe-build-test-{}-{}", name, std::process::id())); + if spaced { + root.push("dir with spaces"); + } + std::fs::create_dir_all(&root).expect("temp workspace created"); + TempWorkspace { root } + } + + fn root(&self) -> String { + self.root.to_string_lossy().to_string() + } + + fn write(&self, relative: &str, contents: &str) { + let path = self.root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("parent created"); + } + std::fs::write(path, contents).expect("file written"); + } +} + +impl Drop for TempWorkspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +/// Option store rooted in its own temp directory. +struct TempOptions { + root: PathBuf, +} + +impl TempOptions { + fn new(name: &str) -> Self { + let mut root = std::env::temp_dir(); + root.push(format!( + "lithe-build-options-{}-{}", + name, + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp options dir created"); + TempOptions { root } + } + + fn stored(&self, workspace_root: &str) -> StoredBuildOptions { + self.load(workspace_root).expect("options load") + } +} + +impl Drop for TempOptions { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +impl BuildOptionsPersistence for TempOptions { + fn load(&self, workspace_root: &str) -> Result { + let path = self.root.join(format!( + "{}.json", + super::store::workspace_state_key(workspace_root) + )); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(StoredBuildOptions::default()) + } + Err(error) => return Err(error.to_string()), + }; + serde_json::from_str(&text).map_err(|error| error.to_string()) + } + + fn save(&self, workspace_root: &str, options: &StoredBuildOptions) -> Result<(), String> { + let path = self.root.join(format!( + "{}.json", + super::store::workspace_state_key(workspace_root) + )); + std::fs::write( + &path, + serde_json::to_string_pretty(options).map_err(|e| e.to_string())?, + ) + .map_err(|error| error.to_string()) + } +} + +/// One fixture package whose enabled state and version the test controls, +/// simulating plugin management without touching shared state. +struct FixedPackages { + plugin_id: String, + manifest: Mutex, + version: Mutex, + enabled: Mutex, +} + +impl FixedPackages { + fn one(manifest: Value) -> Self { + FixedPackages { + plugin_id: manifest["id"].as_str().expect("fixture id").to_string(), + manifest: Mutex::new(manifest), + version: Mutex::new("0.1.0".to_string()), + enabled: Mutex::new(true), + } + } + + fn set_enabled(&self, enabled: bool) { + *self.enabled.lock().expect("enabled flag") = enabled; + } + + fn set_version(&self, version: &str) { + *self.version.lock().expect("version") = version.to_string(); + } + + fn set_manifest(&self, manifest: Value) { + *self.manifest.lock().expect("manifest") = manifest; + } + + fn plugin_id(&self) -> &str { + &self.plugin_id + } + + fn input(&self) -> Option { + if !*self.enabled.lock().expect("enabled flag") { + return None; + } + Some(BuildPackageInput { + plugin_id: self.plugin_id.clone(), + version: self.version.lock().expect("version").clone(), + manifest: self.manifest.lock().expect("manifest").clone(), + }) + } +} + +impl BuildPackageSource for FixedPackages { + fn accepted_packages(&self) -> Vec { + self.input().into_iter().collect() + } + + fn package(&self, plugin_id: &str) -> Option { + if plugin_id == self.plugin_id { + self.input() + } else { + None + } + } +} + +/// Real Core through the shared host envelope. +struct RealCore; + +impl BuildCoreExecutor for RealCore { + fn execute(&self, command: &str, payload: Value) -> Result { + super::scan::core_execute(command, payload) + } +} + +/// Core double with scripted responses, for envelope failure paths. +struct ScriptedCore { + responses: Mutex>>, +} + +impl ScriptedCore { + fn rejected(code: &str, message: &str) -> Self { + ScriptedCore { + responses: Mutex::new(vec![Ok(json!({ + "status": "rejected", + "error": { "code": code, "message": message }, + }))]), + } + } + + fn broken() -> Self { + ScriptedCore { + responses: Mutex::new(vec![Ok(json!({ "status": "mysterious" }))]), + } + } +} + +impl BuildCoreExecutor for ScriptedCore { + fn execute(&self, _command: &str, _payload: Value) -> Result { + self.responses + .lock() + .expect("scripted responses") + .pop() + .expect("scripted response for every call") + } +} + +fn cargo_toml(features: &[(&str, &[&str])]) -> String { + let mut toml = String::from( + "[package]\nname = \"fixture-app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[[bin]]\nname = \"fixture-app\"\npath = \"src/main.rs\"\n", + ); + if !features.is_empty() { + toml.push_str("\n[features]\n"); + for (name, deps) in features { + let values = deps + .iter() + .map(|dep| format!("\"{dep}\"")) + .collect::>() + .join(", "); + toml.push_str(&format!("{name} = [{values}]\n")); + } + } + toml +} + +fn standard_workspace(name: &str) -> TempWorkspace { + let workspace = TempWorkspace::new(name, true); + workspace.write("Cargo.toml", &cargo_toml(&[])); + workspace.write("src/main.rs", "fn main() {}\n"); + workspace +} + +fn view_json(view: &super::build_registry::BuildView) -> Value { + serde_json::to_value(view).expect("view serializes") +} + +fn option_value<'a>(view: &'a Value, root: &str, option_id: &str) -> &'a Value { + view["instances"] + .as_array() + .expect("instances") + .iter() + .find(|instance| instance["root"].as_str() == Some(root)) + .unwrap_or_else(|| panic!("instance at {root}")) + .get("options") + .expect("options list") + .as_array() + .expect("options array") + .iter() + .find(|option| option["id"].as_str() == Some(option_id)) + .unwrap_or_else(|| panic!("option {option_id}")) + .get("value") + .expect("option value") +} + +#[test] +fn detect_reads_the_static_model_and_describes_options() { + let workspace = standard_workspace("detect"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("detect"); + let mut registry = BuildRegistry::new(); + + let view = registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect succeeds"); + let view = view_json(&view); + + assert_eq!(view["instances"].as_array().expect("instances").len(), 1); + let instance = &view["instances"][0]; + assert_eq!(instance["buildSystemID"], json!("cargo")); + assert_eq!(instance["modelSource"], json!("static")); + assert_eq!( + instance["pluginID"], + json!("dev.lithe.fixture.build-static-file") + ); + assert_eq!( + instance["model"]["projects"][0]["name"], + json!("fixture-app") + ); + assert_eq!(instance["stale"], json!(false)); + let kinds: Vec<&str> = instance["options"] + .as_array() + .expect("options") + .iter() + .filter_map(|option| option["kind"].as_str()) + .collect(); + assert!(kinds.contains(&"boolean") && kinds.contains(&"multiSelect")); + assert!(instance["optionFingerprint"].is_string()); +} + +#[test] +fn rejected_manifest_uses_its_original_request_index_for_the_plugin_id() { + struct MixedPackages(Vec); + impl BuildPackageSource for MixedPackages { + fn accepted_packages(&self) -> Vec { + self.0.clone() + } + + fn package(&self, plugin_id: &str) -> Option { + self.0 + .iter() + .find(|package| package.plugin_id == plugin_id) + .cloned() + } + } + + let workspace = standard_workspace("rejection-owner"); + let options = TempOptions::new("rejection-owner"); + let valid = FixedPackages::one(static_fixture()) + .input() + .expect("valid package"); + let mut invalid = valid.clone(); + invalid.plugin_id = "io.example.invalid-build".into(); + invalid.manifest["id"] = json!(invalid.plugin_id); + invalid.manifest["version"] = json!("not-a-version"); + let packages = MixedPackages(vec![valid.clone(), invalid.clone()]); + let mut registry = BuildRegistry::new(); + let view = view_json( + ®istry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("the valid package still detects"), + ); + + assert_eq!(view["instances"][0]["pluginID"], json!(valid.plugin_id)); + assert_eq!(view["issues"].as_array().expect("issues").len(), 1); + assert_eq!(view["issues"][0]["index"], json!(1)); + assert_eq!(view["issues"][0]["pluginID"], json!(invalid.plugin_id)); + assert_eq!(view["issues"][0]["kind"], json!("rejected")); +} + +#[test] +fn malformed_rejection_indices_do_not_blame_an_unrelated_package() { + let workspace = standard_workspace("rejection-index"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("rejection-index"); + let core = ScriptedCore { + responses: Mutex::new(vec![Ok(json!({ + "status":"ok", + "detection": { "instances":[], "shadowed":[], "rejected":[ + {"index":-1,"error":{"code":"invalid"}}, + {"index":99,"error":{"code":"invalid"}}, + {"index":"0","error":{"code":"invalid"}} + ] } + }))]), + }; + let mut registry = BuildRegistry::new(); + let view = view_json( + ®istry + .refresh(&core, &packages, &options, "main", &workspace.root()) + .expect("invalid rejection indices remain diagnostic data"), + ); + + for issue in view["issues"].as_array().expect("issues") { + assert!( + issue.get("pluginID").is_none(), + "unrelated package must not be blamed: {issue}" + ); + } +} + +#[test] +fn option_edits_persist_and_survive_a_registry_rebuild() { + let workspace = standard_workspace("persist"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("persist"); + let mut registry = BuildRegistry::new(); + + let view = view_json( + ®istry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"), + ); + let default_fingerprint = view["instances"][0]["optionFingerprint"] + .as_str() + .expect("fingerprint") + .to_string(); + + let view = view_json( + ®istry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect("option edit"), + ); + assert_eq!(option_value(&view, ".", "release"), &json!(true)); + assert_eq!( + options.stored(&workspace.root()).systems["cargo"].values["release"], + json!(true) + ); + + // A rebuilt registry (app restart) detects with the stored values and + // keeps the changed fingerprint. + let mut rebuilt = BuildRegistry::new(); + let view = view_json( + &rebuilt + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("re-detect"), + ); + assert_eq!(option_value(&view, ".", "release"), &json!(true)); + let fingerprint = view["instances"][0]["optionFingerprint"] + .as_str() + .expect("fingerprint"); + assert_ne!( + fingerprint, default_fingerprint, + "fingerprint follows the edit" + ); +} + +#[test] +fn an_edit_core_rejects_never_reaches_the_storage() { + let workspace = standard_workspace("dropped"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("dropped"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + // An edit whose value Core drops (no model choice, wrong type) is a + // rejected write, not a silent rewrite to the effective value. + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "features", + Some(json!(["vanished-feature"])), + ) + .expect_err("edit with invalid feature"); + assert!(error.contains("rejected by Core"), "{error}"); + assert!(options + .stored(&workspace.root()) + .systems + .get("cargo") + .map(|system| system.values.get("features").is_none()) + .unwrap_or(true)); + + // Refresh-time staleness still rewrites silently: a value stored by an + // older declaration that no instance accepts is replaced with the + // effective value so repeated detections agree. + let mut stale = StoredBuildOptions::default(); + stale.systems.insert( + "cargo".to_string(), + super::build_registry::StoredSystemOptions { + values: [("features".to_string(), json!(["vanished-feature"]))] + .into_iter() + .collect(), + empty_markers: Default::default(), + }, + ); + options.save(&workspace.root(), &stale).expect("seed"); + let view = view_json( + ®istry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("refresh with stale storage"), + ); + assert_eq!(option_value(&view, ".", "features"), &json!([])); + assert_eq!( + options.stored(&workspace.root()).systems["cargo"].values["features"], + json!([]) + ); +} + +#[test] +fn explicit_empty_multiselect_uses_a_marker_and_reset_clears_it() { + let workspace = TempWorkspace::new("marker", false); + workspace.write("Cargo.toml", &cargo_toml(&[("alpha", &[])])); + workspace.write("src/main.rs", "fn main() {}\n"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("marker"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + let view = view_json( + ®istry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "features", + Some(json!([])), + ) + .expect("clear"), + ); + assert_eq!(option_value(&view, ".", "features"), &json!([])); + let stored = options.stored(&workspace.root()); + assert!( + stored.systems["cargo"] + .empty_markers + .get(".") + .expect("marker") + .contains("features"), + "explicit empty stored as a marker" + ); + + let view = view_json( + ®istry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "features", + None, + ) + .expect("reset"), + ); + assert_eq!(option_value(&view, ".", "features"), &json!([])); + let stored = options.stored(&workspace.root()); + assert!( + stored.systems["cargo"].values.get("features").is_none(), + "reset removes the shared override" + ); + assert!( + stored.systems["cargo"] + .empty_markers + .get(".") + .map(|markers| markers.is_empty()) + .unwrap_or(true), + "reset clears the marker" + ); +} + +#[test] +fn editing_one_instance_keeps_sibling_only_selections() { + let workspace = TempWorkspace::new("siblings", false); + workspace.write("a/Cargo.toml", &cargo_toml(&[("alpha", &[])])); + workspace.write("a/src/main.rs", "fn main() {}\n"); + workspace.write("b/Cargo.toml", &cargo_toml(&[("beta", &[])])); + workspace.write("b/src/main.rs", "fn main() {}\n"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("siblings"); + let mut registry = BuildRegistry::new(); + let view = view_json( + ®istry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"), + ); + assert_eq!(view["instances"].as_array().expect("instances").len(), 2); + + // Model choices are offers, not selections: a's alpha only reaches the + // shared store once the user selects it for a. + registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + "a", + "features", + Some(json!(["alpha"])), + ) + .expect("select alpha for a"); + + // Clearing b keeps a's selection in the shared store; b's own explicit + // empty lives in its marker. + let view = view_json( + ®istry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + "b", + "features", + Some(json!([])), + ) + .expect("clear b"), + ); + assert_eq!(option_value(&view, "a", "features"), &json!(["alpha"])); + assert_eq!(option_value(&view, "b", "features"), &json!([])); + let stored = options.stored(&workspace.root()); + assert_eq!( + stored.systems["cargo"].values["features"], + json!(["alpha"]), + "sibling selection survives in the shared store" + ); +} + +#[test] +fn workspace_switch_resets_sessions_and_rejects_stale_writes() { + let first = standard_workspace("switch-a"); + let second = standard_workspace("switch-b"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("switch"); + let mut registry = BuildRegistry::new(); + + let view = registry + .refresh(&RealCore, &packages, &options, "main", &first.root()) + .expect("detect first"); + assert_eq!(view.generation, 0); + + let view = registry + .refresh(&RealCore, &packages, &options, "main", &second.root()) + .expect("detect second"); + assert!(view.generation > 0, "workspace switch bumps the generation"); + + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &first.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("stale write rejected"); + assert!(error.contains("moved to another workspace"), "{error}"); + let error = registry + .sync_check( + &RealCore, + &packages, + "main", + &first.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect_err("stale sync rejected"); + assert!(error.contains("moved to another workspace"), "{error}"); + + // Re-entering an already-visited workspace must rebuild the native + // window session before option editing and watcher checks resume. + let returned = registry + .refresh(&RealCore, &packages, &options, "main", &first.root()) + .expect("return to first"); + assert_eq!(returned.workspace_root, first.root()); + let edited = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &first.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect("edit after return"); + assert_eq!( + option_value(&view_json(&edited), ".", "release"), + &json!(true) + ); + assert!(options.stored(&second.root()).systems.is_empty()); + first.write("Cargo.toml", &cargo_toml(&[("after-return", &[])])); + let synced = registry + .sync_check( + &RealCore, + &packages, + "main", + &first.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect("sync after return"); + assert_eq!(view_json(&synced)["instances"][0]["stale"], json!(true)); +} + +#[test] +fn disabling_the_plugin_clears_sessions_and_blocks_writes() { + let workspace = standard_workspace("disable"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("disable"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + packages.set_enabled(false); + let view = registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect after disable"); + assert!(view.instances.is_empty(), "sessions cleared"); + + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("write after disable"); + assert!(error.contains("no longer an enabled plugin"), "{error}"); +} + +#[test] +fn file_changes_mark_the_model_stale_through_the_sync_fingerprint() { + let workspace = standard_workspace("stale"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("stale"); + let mut registry = BuildRegistry::new(); + let initial = registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + assert!(view_json(&initial)["instances"][0]["syncDigest"].is_string()); + + // The first watcher event after refresh must compare to the loaded + // model, not establish a baseline from already-changed files. + workspace.write("Cargo.toml", &cargo_toml(&[("extra", &[])])); + let view = view_json( + ®istry + .sync_check( + &RealCore, + &packages, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect("first sync after file change"), + ); + assert_eq!(view["instances"][0]["stale"], json!(true)); + assert!(view["instances"][0]["syncDigest"].is_string()); + + // Refresh rebuilds from disk and clears the stale flag. + let view = view_json( + ®istry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("reload"), + ); + assert_eq!(view["instances"][0]["stale"], json!(false)); + let baseline = view["instances"][0]["syncDigest"].clone(); + let unchanged = registry + .sync_check( + &RealCore, + &packages, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect("unchanged after reload"); + assert_eq!(view_json(&unchanged)["instances"][0]["stale"], json!(false)); + assert_eq!( + view_json(&unchanged)["instances"][0]["syncDigest"], + baseline + ); + + workspace.write("Cargo.toml", &cargo_toml(&[("next-change", &[])])); + let changed_again = registry + .sync_check( + &RealCore, + &packages, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect("first event after the second reload"); + assert_eq!( + view_json(&changed_again)["instances"][0]["stale"], + json!(true) + ); +} + +/// Changes disk at a deterministic Core return boundary, with no watcher, +/// sleep, thread, or assumption about filesystem timestamp resolution. +struct ChangeAfterCoreCall<'a> { + workspace: &'a TempWorkspace, + command: &'static str, + changed: AtomicBool, +} + +impl BuildCoreExecutor for ChangeAfterCoreCall<'_> { + fn execute(&self, command: &str, payload: Value) -> Result { + let response = RealCore.execute(command, payload)?; + if command == self.command && !self.changed.swap(true, Ordering::SeqCst) { + self.workspace + .write("Cargo.toml", &cargo_toml(&[("new-feature", &[])])); + } + Ok(response) + } +} + +#[test] +fn a_change_between_detection_and_the_baseline_is_read_into_the_model() { + let workspace = standard_workspace("baseline-before-model"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("baseline-before-model"); + let core = ChangeAfterCoreCall { + workspace: &workspace, + command: "build.detect", + changed: AtomicBool::new(false), + }; + let mut registry = BuildRegistry::new(); + let loaded = registry + .refresh(&core, &packages, &options, "main", &workspace.root()) + .expect("refresh"); + let loaded = view_json(&loaded); + assert_eq!(loaded["instances"][0]["stale"], json!(false)); + assert!( + loaded["instances"][0]["options"] + .as_array() + .expect("options") + .iter() + .any(|option| { + option["id"] == "features" + && option["choices"] + .as_array() + .expect("choices") + .iter() + .any(|choice| choice["value"] == "new-feature") + }), + "the published model rereads the bytes whose fingerprint is stored" + ); +} + +#[test] +fn a_change_while_the_model_loads_is_already_stale_at_refresh_completion() { + let workspace = standard_workspace("change-during-model"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("change-during-model"); + let core = ChangeAfterCoreCall { + workspace: &workspace, + command: "build.decodeModel", + changed: AtomicBool::new(false), + }; + let mut registry = BuildRegistry::new(); + let loaded = registry + .refresh(&core, &packages, &options, "main", &workspace.root()) + .expect("refresh"); + assert_eq!(view_json(&loaded)["instances"][0]["stale"], json!(true)); + let refreshed = registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("reload stable files"); + assert_eq!(view_json(&refreshed)["instances"][0]["stale"], json!(false)); +} + +#[test] +fn windows_keep_their_own_sessions_for_the_same_workspace() { + let workspace = standard_workspace("windows"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("windows"); + let mut registry = BuildRegistry::new(); + registry + .refresh( + &RealCore, + &packages, + &options, + "window-a", + &workspace.root(), + ) + .expect("detect a"); + registry + .refresh( + &RealCore, + &packages, + &options, + "window-b", + &workspace.root(), + ) + .expect("detect b"); + + registry + .set_option( + &RealCore, + &packages, + &options, + "window-a", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect("edit in a"); + + // Window b's stored session still shows the value from its own last + // refresh until that window refreshes itself. + let view_b = view_json(®istry.view("window-b")); + assert_eq!(option_value(&view_b, ".", "release"), &json!(false)); + let view_b = view_json( + ®istry + .refresh( + &RealCore, + &packages, + &options, + "window-b", + &workspace.root(), + ) + .expect("refresh b"), + ); + assert_eq!(option_value(&view_b, ".", "release"), &json!(true)); +} + +#[test] +fn a_non_directory_root_detects_as_empty_without_panicking() { + // Core tolerates non-directory roots and reports no instances; the + // registry must surface that as an empty view, not an error. + let workspace = standard_workspace("invalid"); + let file_root = workspace.root.join("Cargo.toml"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("invalid"); + let mut registry = BuildRegistry::new(); + + let view = registry + .refresh( + &RealCore, + &packages, + &options, + "main", + &file_root.to_string_lossy(), + ) + .expect("core answers for non-directory roots"); + assert!(view.instances.is_empty()); +} + +#[test] +fn core_rejections_and_broken_envelopes_surface_as_errors() { + let workspace = standard_workspace("envelope"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("envelope"); + + let mut registry = BuildRegistry::new(); + let error = registry + .refresh( + &ScriptedCore::rejected("plugin.build.invalidSelection", "bad request"), + &packages, + &options, + "main", + &workspace.root(), + ) + .expect_err("rejected detect"); + assert!(error.contains("plugin.build.invalidSelection"), "{error}"); + + let mut registry = BuildRegistry::new(); + let error = registry + .refresh( + &ScriptedCore::broken(), + &packages, + &options, + "main", + &workspace.root(), + ) + .expect_err("unexpected status"); + assert!(error.contains("unexpected status"), "{error}"); +} + +#[test] +fn an_invalid_option_value_is_rejected_without_touching_storage() { + let workspace = standard_workspace("invalid-value"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("invalid-value"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!("yes")), + ) + .expect_err("a string is not a boolean value"); + assert!(error.contains("rejected by Core"), "{error}"); + + // The storage and the visible model keep the previous state. + let stored = options.stored(&workspace.root()); + assert!(stored + .systems + .get("cargo") + .map(|system| system.values.get("release").is_none()) + .unwrap_or(true)); + let view = view_json(®istry.view("main")); + assert_eq!(option_value(&view, ".", "release"), &json!(false)); + + // An unknown multi-select choice is likewise a rejected edit. + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "features", + Some(json!(["vanished-feature"])), + ) + .expect_err("unknown choice"); + assert!(error.contains("rejected by Core"), "{error}"); + assert!( + options + .stored(&workspace.root()) + .systems + .get("cargo") + .map(|system| system.values.get("features").is_none()) + .unwrap_or(true), + "storage stayed clean after the rejected edit" + ); +} + +/// Persistence double whose next save can be made to fail. +struct FlakySave { + inner: TempOptions, + fail_next: Mutex, +} + +impl FlakySave { + fn new(name: &str) -> Self { + FlakySave { + inner: TempOptions::new(name), + fail_next: Mutex::new(false), + } + } + + fn fail_next_save(&self) { + *self.fail_next.lock().expect("fail flag") = true; + } +} + +impl BuildOptionsPersistence for FlakySave { + fn load(&self, workspace_root: &str) -> Result { + self.inner.load(workspace_root) + } + + fn save(&self, workspace_root: &str, options: &StoredBuildOptions) -> Result<(), String> { + if *self.fail_next.lock().expect("fail flag") { + *self.fail_next.lock().expect("fail flag") = false; + return Err("disk full".to_string()); + } + self.inner.save(workspace_root, options) + } +} + +#[test] +fn a_failed_save_leaves_the_previous_options_untouched() { + let workspace = standard_workspace("save-fail"); + let packages = FixedPackages::one(static_fixture()); + let options = FlakySave::new("save-fail"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + options.fail_next_save(); + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("save failure surfaces"); + assert!(error.contains("disk full"), "{error}"); + assert!( + options + .inner + .stored(&workspace.root()) + .systems + .get("cargo") + .map(|system| system.values.get("release").is_none()) + .unwrap_or(true), + "storage unchanged after the failed save" + ); +} + +/// Core double with an ordered response script and a record of payloads. +struct ScriptedSequence { + responses: Mutex>>, + seen: Mutex>, +} + +impl ScriptedSequence { + fn ok_then_detect_error() -> Self { + ScriptedSequence { + responses: Mutex::new(vec![ + // set_option validation: resolveOptions ok. + Ok(json!({ + "status": "ok", + "options": { "options": [ + { "id": "release", "kind": "boolean", "value": true, "choices": [] }, + { "id": "features", "kind": "multiSelect", "value": [], "choices": [] }, + { "id": "offline", "kind": "boolean", "value": false, "choices": [] } + ], "dropped": [], "fingerprint": "validated" } + })), + // The re-detection after the save fails. + Ok(json!({ + "status": "rejected", + "error": { "code": "plugin.build.invalidSelection", "message": "boom" } + })), + ]), + seen: Mutex::new(Vec::new()), + } + } + + fn sync_response() -> Self { + ScriptedSequence { + responses: Mutex::new(vec![Ok(json!({ + "status": "ok", + "sync": { "fingerprint": "synced", "watch": [] } + }))]), + seen: Mutex::new(Vec::new()), + } + } +} + +impl BuildCoreExecutor for ScriptedSequence { + fn execute(&self, command: &str, payload: Value) -> Result { + self.seen + .lock() + .expect("seen") + .push((command.to_string(), payload.clone())); + self.responses + .lock() + .expect("scripted responses") + .pop() + .expect("scripted response for every call") + } +} + +#[test] +fn a_failed_redetection_rolls_back_the_saved_options() { + let workspace = standard_workspace("rollback"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("rollback"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + let generation_before = registry.view("main").generation; + + let core = ScriptedSequence::ok_then_detect_error(); + let error = registry + .set_option( + &core, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("re-detection failure surfaces"); + assert!(error.contains("plugin.build.invalidSelection"), "{error}"); + + // The saved candidate was rolled back and the sessions are untouched. + let stored = options.stored(&workspace.root()); + assert!( + stored + .systems + .get("cargo") + .map(|system| system.values.get("release").is_none()) + .unwrap_or(true), + "storage rolled back to the pre-edit values" + ); + let view = view_json(®istry.view("main")); + assert_eq!(option_value(&view, ".", "release"), &json!(false)); + assert_eq!(registry.view("main").generation, generation_before); +} + +#[test] +fn a_replaced_package_refuses_sync_and_option_writes() { + let workspace = standard_workspace("replaced"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("replaced"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + packages.set_version("0.2.0"); + let error = registry + .sync_check( + &RealCore, + &packages, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect_err("version change refuses sync"); + assert!(error.contains("changed since"), "{error}"); + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("version change refuses writes"); + assert!(error.contains("changed since"), "{error}"); + + // A disabled package refuses the same queries. + packages.set_version("0.1.0"); + packages.set_enabled(false); + let error = registry + .sync_check( + &RealCore, + &packages, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect_err("disabled package refuses sync"); + assert!(error.contains("no longer an enabled plugin"), "{error}"); +} + +#[test] +fn drop_plugin_revokes_sessions_and_blocks_writes() { + let workspace = standard_workspace("revoke"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("revoke"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + registry.drop_plugin(packages.plugin_id()); + let view = view_json(®istry.view("main")); + assert!(view["instances"].as_array().expect("instances").is_empty()); + + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("revoked session refuses writes"); + assert!(error.contains("no build instance"), "{error}"); +} + +#[test] +fn closing_the_workspace_clears_sessions_before_a_reopen() { + let workspace = standard_workspace("close"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("close"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + registry.close_workspace("main", &workspace.root()); + let view = view_json(®istry.view("main")); + assert!(view["instances"].as_array().expect("instances").is_empty()); + let generation = view["generation"].as_u64().expect("generation"); + assert!(generation > 0, "close bumps the generation"); + + let error = registry + .set_option( + &RealCore, + &packages, + &options, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + "release", + Some(json!(true)), + ) + .expect_err("closed workspace refuses writes"); + assert!(error.contains("no build instance"), "{error}"); +} + +#[test] +fn sync_sends_the_current_verified_manifest_not_the_session_snapshot() { + let workspace = standard_workspace("fresh-manifest"); + let packages = FixedPackages::one(static_fixture()); + let options = TempOptions::new("fresh-manifest"); + let mut registry = BuildRegistry::new(); + registry + .refresh(&RealCore, &packages, &options, "main", &workspace.root()) + .expect("detect"); + + // Same-version reinstall: the bytes and therefore the manifest change + // under the same plugin ID and version. + let mut replaced = static_fixture(); + replaced["displayName"] = json!("Reinstalled Fixture"); + packages.set_manifest(replaced.clone()); + + let core = ScriptedSequence::sync_response(); + registry + .sync_check( + &core, + &packages, + "main", + &workspace.root(), + packages.plugin_id(), + "cargo", + ".", + ) + .expect("sync succeeds with the fresh package"); + + let seen = core.seen.lock().expect("seen"); + let (_, payload) = seen + .iter() + .find(|(command, _)| command == "build.syncFingerprint") + .expect("sync request recorded"); + assert_eq!( + payload["manifest"]["displayName"], + json!("Reinstalled Fixture") + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/details.rs b/windows/tauri/src-tauri/src/plugin_host/details.rs new file mode 100644 index 0000000000..d0d02c91d7 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/details.rs @@ -0,0 +1,825 @@ +//! Read-only detail model for packages already discovered by the plugin host. +//! +//! Callers identify a cached package by plugin ID and catalog source; they +//! never submit a filesystem path. Every live package read holds the same +//! lifecycle lease used by launch probes, stays inside the owning package +//! root, and re-verifies the bytes before reporting trust or provenance. + +use super::signature::{self, InstallOrigin}; +use super::store::{self, InstallationRecord}; +use super::{CatalogRejection, PluginHostState, ScannedPackage}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +/// Stable frontend-facing failure from the detail read boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDetailsError { + pub code: String, + pub message: String, +} + +impl PluginDetailsError { + fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.to_string(), + message: message.into(), + } + } +} + +/// Complete detail snapshot for one package identity. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDetails { + pub identity: PluginDetailsIdentity, + pub display_name: String, + pub vendor_name: Option, + pub description: Option, + pub localized_descriptions: BTreeMap, + pub api_version: u32, + pub enabled: bool, + pub status: &'static str, + pub rejection: Option, + pub settings: Vec, + pub primary_language_id: Option, + pub features: Vec, + pub permissions: Vec, + /// Full canonical program objects returned by Core install review. + pub programs: Vec, + pub versions: PluginVersionDetails, + pub operations: PluginDetailOperations, +} + +/// Selection identity that must travel with every asynchronous response. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDetailsIdentity { + pub plugin_id: String, + pub version: String, + pub source: String, +} + +/// One declaration-derived feature and whether this host can execute it. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginFeature { + pub kind: &'static str, + pub subject: String, + pub title: String, + /// `available` means the corresponding capability is implemented by this + /// host. `declaredOnly` is informative and never promises execution. + pub availability: &'static str, + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginVersionDetails { + pub current: VersionProvenance, + pub previous: Option, + /// Marketplace index data is supplied by M2. Until it is connected, the + /// detail surface must not invent an update or "latest" state. + pub update_status: &'static str, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VersionProvenance { + pub version: String, + pub origin: &'static str, + pub channel: &'static str, + pub source_id: Option, + pub source_name: Option, + pub repository: Option, + pub file_name: Option, + pub archive_sha256: Option, + pub content_digest: Option, + pub verification: ProvenanceVerification, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProvenanceVerification { + /// `publisherSignature`, `hostSeal`, `approvedDigest`, `rejected`, or + /// `unknown`. Only the first three are successful verification results. + pub status: &'static str, + pub key_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDetailOperations { + pub can_set_enabled: bool, + pub can_uninstall: bool, + pub can_rollback: bool, +} + +/// Reads one package already present in the host catalog. `source` resolves +/// duplicate bundled/installed IDs without exposing a path-shaped selector. +#[tauri::command] +pub async fn declarative_plugins_details( + app: AppHandle, + plugin_id: String, + source: String, +) -> Result { + // Package digests can cover a bundled language server; keep their file IO + // off the UI and async executor threads while the worker owns its lease. + tauri::async_runtime::spawn_blocking(move || read_catalog_details(app, plugin_id, source)) + .await + .map_err(|error| PluginDetailsError::new("plugin.details.readFailed", error.to_string()))? +} + +fn read_catalog_details( + app: AppHandle, + plugin_id: String, + source: String, +) -> Result { + if source != "bundled" && source != "installed" { + return Err(PluginDetailsError::new( + "plugin.details.invalidSource", + "Plugin details can only be read from the bundled or installed catalog.", + )); + } + let _lease = app + .state::() + .acquire(&plugin_id) + .map_err(|message| PluginDetailsError::new("plugin.details.changing", message))?; + let package = app + .state::() + .packages() + .into_iter() + .find(|package| package.entry.plugin_id == plugin_id && package.entry.source == source) + .ok_or_else(|| { + PluginDetailsError::new( + "plugin.details.notFound", + "The selected plugin is no longer present. Refresh the plugin list.", + ) + })?; + let install_root = store::install_root(&app) + .map_err(|message| PluginDetailsError::new("plugin.details.storageUnavailable", message))?; + let bundled_root = store::bundled_root(&app) + .map_err(|message| PluginDetailsError::new("plugin.details.storageUnavailable", message))?; + build_details(&package, &install_root, &bundled_root) +} + +pub(crate) fn build_details( + package: &ScannedPackage, + install_root: &Path, + bundled_root: &Path, +) -> Result { + let expected_root = match package.entry.source { + "bundled" => bundled_root, + "installed" => install_root, + _ => { + return Err(PluginDetailsError::new( + "plugin.details.invalidSource", + "The cached plugin source is not supported by the detail reader.", + )) + } + }; + ensure_within(package.root.as_path(), expected_root)?; + + let record = if package.entry.source == "installed" { + read_installation_record(install_root, &package.entry.plugin_id) + } else { + Ok(None) + }; + if package.entry.status == "rejected" { + let record = record.ok().flatten(); + return Ok(quarantined_details(package, record.as_ref())); + } + let record = record.map_err(|message| { + PluginDetailsError::new("plugin.details.installationRecordChanged", message) + })?; + if package.entry.source == "installed" && record.is_none() { + return Err(PluginDetailsError::new( + "plugin.details.installationRecordChanged", + "The installation record disappeared after the catalog was scanned. Refresh the plugin list.", + )); + } + + // The scan cache selects the package, but detail content comes from the + // live package under the lease. This also catches edits after a scan. + let manifest = read_manifest(&package.root)?; + let normalized = super::scan::validate_manifest(&manifest).map_err(|rejection| { + PluginDetailsError::new( + "plugin.details.packageChanged", + format!("{}: {}", rejection.code, rejection.message), + ) + })?; + if normalized["id"].as_str() != Some(package.entry.plugin_id.as_str()) + || normalized["version"].as_str() != Some(package.entry.version.as_str()) + { + return Err(PluginDetailsError::new( + "plugin.details.packageChanged", + "The package identity changed after the catalog was scanned. Refresh the plugin list.", + )); + } + let trust = package.trust.as_ref().ok_or_else(|| { + PluginDetailsError::new( + "plugin.details.trustUnavailable", + "The accepted package has no verified trust evidence.", + ) + })?; + if let Some(record) = &record { + if record.schema_version != 2 + || record.plugin_id != package.entry.plugin_id + || record.version != package.entry.version + || record.origin != Some(trust.origin) + || record.active_content_digest() != trust.expected_content_digest.as_deref() + { + return Err(PluginDetailsError::new( + "plugin.details.installationRecordChanged", + "The active installation no longer matches the catalog package. Refresh the plugin list.", + )); + } + } + signature::verify_for_origin( + &package.root, + &package.entry.plugin_id, + &package.entry.version, + &manifest, + trust.origin, + trust.expected_content_digest.as_deref(), + Some(bundled_root), + &signature::trusted_keys(), + ) + .map_err(|error| { + PluginDetailsError::new( + "plugin.details.packageChanged", + format!( + "The package no longer passes verification: {}", + error.message() + ), + ) + })?; + + let approved_programs = record.as_ref().map(active_approved_programs).transpose()?; + let review = install_review(&manifest, approved_programs.as_deref())?; + let current = current_provenance( + &package.entry.version, + trust.origin, + record.as_ref(), + &package.root, + )?; + let previous = record.as_ref().and_then(previous_provenance); + let settings = normalized["settings"] + .as_array() + .cloned() + .unwrap_or_default(); + Ok(PluginDetails { + identity: PluginDetailsIdentity { + plugin_id: package.entry.plugin_id.clone(), + version: package.entry.version.clone(), + source: package.entry.source.to_string(), + }, + display_name: normalized["displayName"] + .as_str() + .unwrap_or(package.entry.display_name.as_str()) + .to_string(), + vendor_name: normalized["vendor"]["displayName"] + .as_str() + .map(str::to_string), + description: normalized["description"].as_str().map(str::to_string), + localized_descriptions: localized_descriptions(&normalized), + api_version: package.entry.api_version, + enabled: record + .as_ref() + .map_or(package.entry.enabled, |record| record.enabled), + status: "accepted", + rejection: None, + primary_language_id: normalized["languageServers"] + .as_array() + .and_then(|servers| servers.first()) + .and_then(|server| server["languageID"].as_str()) + .map(str::to_string), + settings, + features: features(&normalized), + permissions: review.permissions, + programs: review.programs, + versions: PluginVersionDetails { + current, + previous, + update_status: "unknown", + }, + operations: PluginDetailOperations { + can_set_enabled: package.entry.source == "installed", + can_uninstall: package.entry.source == "installed", + can_rollback: record + .as_ref() + .and_then(|record| record.previous_version.as_ref()) + .is_some(), + }, + }) +} + +fn quarantined_details( + package: &ScannedPackage, + record: Option<&InstallationRecord>, +) -> PluginDetails { + let current = VersionProvenance { + version: package.entry.version.clone(), + origin: "unknown", + channel: "unknown", + source_id: None, + source_name: None, + repository: None, + file_name: None, + archive_sha256: None, + content_digest: None, + verification: ProvenanceVerification { + status: "rejected", + key_id: None, + }, + }; + PluginDetails { + identity: PluginDetailsIdentity { + plugin_id: package.entry.plugin_id.clone(), + version: package.entry.version.clone(), + source: package.entry.source.to_string(), + }, + display_name: package.entry.display_name.clone(), + vendor_name: None, + description: None, + localized_descriptions: BTreeMap::new(), + api_version: package.entry.api_version, + enabled: false, + status: "rejected", + rejection: package.entry.rejection.clone(), + settings: Vec::new(), + primary_language_id: None, + features: Vec::new(), + permissions: Vec::new(), + programs: Vec::new(), + versions: PluginVersionDetails { + current, + previous: record.and_then(previous_provenance), + update_status: "unknown", + }, + operations: PluginDetailOperations { + can_set_enabled: false, + can_uninstall: package.entry.source == "installed", + can_rollback: record + .and_then(|record| record.previous_version.as_ref()) + .is_some(), + }, + } +} + +struct InstallReview { + permissions: Vec, + programs: Vec, +} + +fn install_review( + manifest: &Value, + approved_programs: Option<&[Value]>, +) -> Result { + let outcome = super::scan::core_execute( + "plugins.marketplace.installReview", + json!({ + "manifest": manifest, + "hostCapabilities": super::host_capabilities(), + "approvedPrograms": approved_programs, + }), + ) + .map_err(|message| PluginDetailsError::new("plugin.details.reviewUnavailable", message))?; + if outcome["status"] != json!("ok") { + return Err(PluginDetailsError::new( + "plugin.details.reviewRejected", + outcome["error"]["message"] + .as_str() + .unwrap_or("Core could not review the installed package."), + )); + } + if approved_programs.is_some() + && outcome["review"]["newPrograms"] + .as_array() + .is_some_and(|programs| !programs.is_empty()) + { + return Err(PluginDetailsError::new( + "plugin.details.approvalChanged", + "The program declarations no longer match the installation approval. Reinstall the plugin before using it.", + )); + } + Ok(InstallReview { + permissions: strings(&outcome["review"]["permissions"]), + programs: outcome["review"]["programs"] + .as_array() + .cloned() + .unwrap_or_default(), + }) +} + +fn read_manifest(root: &Path) -> Result { + let text = std::fs::read_to_string(root.join("plugin.json")).map_err(|error| { + PluginDetailsError::new( + "plugin.details.packageChanged", + format!("plugin.json could not be read: {error}"), + ) + })?; + serde_json::from_str(&text).map_err(|error| { + PluginDetailsError::new( + "plugin.details.packageChanged", + format!("plugin.json is no longer valid JSON: {error}"), + ) + }) +} + +fn read_installation_record( + install_root: &Path, + plugin_id: &str, +) -> Result, String> { + let component = Path::new(plugin_id); + if component + .parent() + .is_some_and(|parent| parent != Path::new("")) + || component.file_name().and_then(|value| value.to_str()) != Some(plugin_id) + || matches!(plugin_id, "" | "." | "..") + { + return Err("The selected plugin ID is not a safe store component.".to_string()); + } + let path = install_root.join(plugin_id).join("installation.json"); + if path.exists() { + ensure_within(&path, install_root).map_err(|error| error.message)?; + } + match std::fs::read_to_string(&path) { + Ok(text) => serde_json::from_str(&text) + .map(Some) + .map_err(|error| format!("installation.json could not be decoded: {error}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("installation.json could not be read: {error}")), + } +} + +fn ensure_within(package_root: &Path, expected_root: &Path) -> Result<(), PluginDetailsError> { + let root = expected_root.canonicalize().map_err(|error| { + PluginDetailsError::new( + "plugin.details.storageUnavailable", + format!("The plugin store could not be resolved: {error}"), + ) + })?; + let package = package_root.canonicalize().map_err(|error| { + PluginDetailsError::new( + "plugin.details.packageChanged", + format!("The selected package could not be resolved: {error}"), + ) + })?; + if !package.starts_with(&root) || package == root { + return Err(PluginDetailsError::new( + "plugin.details.pathBoundary", + "The selected package is outside its catalog store.", + )); + } + Ok(()) +} + +fn active_approved_programs(record: &InstallationRecord) -> Result, PluginDetailsError> { + let values = match (&record.marketplace, &record.disk) { + (Some(marketplace), None) => &marketplace.approved_programs, + (None, Some(disk)) => &disk.approved_programs, + _ => { + return Err(PluginDetailsError::new( + "plugin.details.provenanceUnavailable", + "The active installation has no single approved program record.", + )) + } + }; + values + .iter() + .map(|program| { + serde_json::from_str(program).map_err(|error| { + PluginDetailsError::new( + "plugin.details.provenanceUnavailable", + format!("An approved program declaration is unreadable: {error}"), + ) + }) + }) + .collect() +} + +fn current_provenance( + version: &str, + origin: InstallOrigin, + record: Option<&InstallationRecord>, + package_root: &Path, +) -> Result { + let key_id = if signature::has_signature_file(package_root) { + Some( + signature::read_signature_document(package_root) + .map_err(|error| { + PluginDetailsError::new( + "plugin.details.packageChanged", + format!( + "The verified signature document is unreadable: {}", + error.message() + ), + ) + })? + .key_id, + ) + } else { + None + }; + let verification = ProvenanceVerification { + status: if key_id.is_some() { + "publisherSignature" + } else if origin == InstallOrigin::Bundled { + "hostSeal" + } else { + "approvedDigest" + }, + key_id, + }; + let mut value = VersionProvenance { + version: version.to_string(), + origin: origin_name(origin), + channel: if origin == InstallOrigin::Bundled { + "bundled" + } else { + "unknown" + }, + source_id: None, + source_name: None, + repository: None, + file_name: None, + archive_sha256: None, + content_digest: None, + verification, + }; + if let Some(record) = record { + apply_provenance( + &mut value, + record.marketplace.as_ref(), + record.disk.as_ref(), + ); + } + Ok(value) +} + +fn previous_provenance(record: &InstallationRecord) -> Option { + let version = record.previous_version.as_ref()?; + let origin = record.previous_origin?; + let mut value = VersionProvenance { + version: version.clone(), + origin: origin_name(origin), + channel: if origin == InstallOrigin::Bundled { + "bundled" + } else { + "unknown" + }, + source_id: None, + source_name: None, + repository: None, + file_name: None, + archive_sha256: None, + content_digest: None, + // Previous bytes are verified only when rollback begins; the record + // is provenance, not proof that the dormant directory is unchanged. + verification: ProvenanceVerification { + status: "unknown", + key_id: None, + }, + }; + apply_provenance( + &mut value, + record.previous_marketplace.as_ref(), + record.previous_disk.as_ref(), + ); + Some(value) +} + +fn apply_provenance( + target: &mut VersionProvenance, + marketplace: Option<&store::MarketplaceProvenance>, + disk: Option<&store::DiskProvenance>, +) { + match (marketplace, disk) { + (Some(marketplace), None) => { + target.channel = "marketplace"; + target.source_id = Some(marketplace.source_id.clone()); + target.source_name = Some(marketplace.source_name.clone()); + target.repository = Some(marketplace.repository.clone()); + target.archive_sha256 = Some(marketplace.archive_sha256.clone()); + target.content_digest = Some(marketplace.content_digest.clone()); + } + (None, Some(disk)) => { + target.channel = "disk"; + target.file_name = Some(disk.file_name.clone()); + target.archive_sha256 = disk.archive_sha256.clone(); + target.content_digest = Some(disk.content_digest.clone()); + } + _ => {} + } +} + +fn origin_name(origin: InstallOrigin) -> &'static str { + match origin { + InstallOrigin::Bundled => "bundled", + InstallOrigin::Marketplace => "marketplace", + InstallOrigin::Community => "community", + } +} + +fn localized_descriptions(plugin: &Value) -> BTreeMap { + plugin["localizedDescriptions"] + .as_object() + .map(|descriptions| { + descriptions + .iter() + .filter_map(|(locale, value)| { + value + .as_str() + .map(|description| (locale.clone(), description.to_string())) + }) + .collect() + }) + .unwrap_or_default() +} + +fn features(plugin: &Value) -> Vec { + let language_titles: HashMap = plugin["languages"] + .as_array() + .into_iter() + .flatten() + .filter_map(|language| { + Some(( + language["id"].as_str()?.to_string(), + language["displayName"].as_str()?.to_string(), + )) + }) + .collect(); + let mut result = Vec::new(); + for language in array(plugin, "languages") { + push_feature( + &mut result, + "language", + &language["id"], + &language["displayName"], + "languages", + Some(json!({ + "fileExtensions": language["fileExtensions"], + "fileNames": language["fileNames"], + })), + ); + } + for server in array(plugin, "languageServers") { + let id = server["languageID"].as_str().unwrap_or_default(); + result.push(PluginFeature { + kind: "languageServer", + subject: id.to_string(), + title: language_titles + .get(id) + .cloned() + .unwrap_or_else(|| id.to_string()), + availability: availability("languageServers"), + detail: server + .get("launch") + .and_then(|launch| launch.get("kind")) + .cloned() + .map(|kind| json!({ "launchKind": kind })), + }); + } + for provider in array(plugin, "executionProviders") { + let id = provider["languageID"].as_str().unwrap_or_default(); + let title = language_titles + .get(id) + .cloned() + .unwrap_or_else(|| id.to_string()); + if provider.get("run").is_some() { + result.push(PluginFeature { + kind: "run", + subject: id.to_string(), + title: title.clone(), + availability: availability("executionProviders"), + detail: None, + }); + } + if provider.get("test").is_some() { + result.push(PluginFeature { + kind: "test", + subject: id.to_string(), + title, + availability: availability("executionProviders"), + detail: None, + }); + } + } + for build in array(plugin, "buildSystems") { + push_feature( + &mut result, + "buildSystem", + &build["id"], + &build["displayName"], + "buildSystems", + None, + ); + } + for generator in array(plugin, "projectGenerators") { + push_feature( + &mut result, + "projectGenerator", + &generator["id"], + &generator["displayName"], + "projectGenerators", + None, + ); + } + if let Some(ui) = plugin.get("ui") { + for window in array(ui, "toolWindows") { + push_feature( + &mut result, + "toolWindow", + &window["id"], + &window["title"], + "ui", + None, + ); + } + for command in array(ui, "commands") { + push_feature( + &mut result, + "command", + &command["id"], + &command["title"], + "ui", + None, + ); + } + } + result.sort_by(|left, right| { + feature_rank(left.kind) + .cmp(&feature_rank(right.kind)) + .then_with(|| left.title.cmp(&right.title)) + .then_with(|| left.subject.cmp(&right.subject)) + }); + result +} + +fn push_feature( + result: &mut Vec, + kind: &'static str, + subject: &Value, + title: &Value, + capability: &str, + detail: Option, +) { + let subject = subject.as_str().unwrap_or_default().to_string(); + result.push(PluginFeature { + kind, + title: title.as_str().unwrap_or(subject.as_str()).to_string(), + subject, + availability: availability(capability), + detail, + }); +} + +fn availability(capability: &str) -> &'static str { + if super::host_capabilities() + .get(capability) + .and_then(Value::as_array) + .is_some_and(|versions| !versions.is_empty()) + { + "available" + } else { + "declaredOnly" + } +} + +fn feature_rank(kind: &str) -> usize { + [ + "language", + "languageServer", + "run", + "test", + "buildSystem", + "projectGenerator", + "toolWindow", + "command", + ] + .iter() + .position(|candidate| *candidate == kind) + .unwrap_or(usize::MAX) +} + +fn array<'a>(value: &'a Value, key: &str) -> impl Iterator { + value[key].as_array().into_iter().flatten() +} + +fn strings(value: &Value) -> Vec { + value + .as_array() + .into_iter() + .flatten() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() +} + +#[cfg(test)] +pub(crate) fn installation_record_path(root: &Path, plugin_id: &str) -> PathBuf { + root.join(plugin_id).join("installation.json") +} diff --git a/windows/tauri/src-tauri/src/plugin_host/details_tests.rs b/windows/tauri/src-tauri/src/plugin_host/details_tests.rs new file mode 100644 index 0000000000..ae98b030d0 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/details_tests.rs @@ -0,0 +1,355 @@ +//! Regression coverage for the package-backed plugin detail model. + +use super::details::{self, PluginDetailsError}; +use super::scan; +use super::signature; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json" +); + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new(label: &str) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(1); + let path = std::env::temp_dir().join(format!( + "lithe-plugin-details-{label}-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&path).expect("test directory"); + Self(path) + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn fixture() -> Value { + serde_json::from_str(&std::fs::read_to_string(FIXTURE).expect("fixture is readable")) + .expect("fixture is JSON") +} + +fn community_manifest() -> Value { + let mut manifest = fixture(); + manifest["id"] = json!("io.example.details-fixture"); + manifest["displayName"] = json!("Detail Fixture"); + manifest["vendor"]["signatureRequirement"] = json!("none"); + manifest +} + +fn review_programs(manifest: &Value) -> Vec { + let outcome = scan::core_execute( + "plugins.marketplace.installReview", + json!({ + "manifest": manifest, + "hostCapabilities": super::host_capabilities(), + }), + ) + .expect("Core install review"); + assert_eq!(outcome["status"], json!("ok"), "review outcome: {outcome}"); + outcome["review"]["programs"] + .as_array() + .expect("programs") + .iter() + .map(|program| serde_json::to_string(program).expect("canonical program JSON")) + .collect() +} + +fn write_installed_package(install_root: &Path, manifest: &Value, enabled: bool) -> PathBuf { + let plugin_id = manifest["id"].as_str().expect("plugin ID"); + let version = manifest["version"].as_str().expect("version"); + let package_root = install_root.join(plugin_id).join(version); + std::fs::create_dir_all(&package_root).expect("package directory"); + std::fs::write( + package_root.join("plugin.json"), + serde_json::to_vec_pretty(manifest).expect("manifest bytes"), + ) + .expect("manifest file"); + let content_digest = signature::content_digest(&package_root).expect("content digest"); + let record = json!({ + "schemaVersion": 2, + "pluginId": plugin_id, + "version": version, + "previousVersion": "0.0.9", + "enabled": enabled, + "origin": "community", + "previousOrigin": "marketplace", + "disk": { + "fileName": "detail-fixture.zip", + "archiveSha256": "archive-current", + "contentDigest": content_digest, + "approvedPrograms": review_programs(manifest), + }, + "previousMarketplace": { + "sourceId": "lithe-store", + "sourceName": "Lithe Store", + "repository": "https://example.invalid/plugins", + "archiveSha256": "archive-previous", + "contentDigest": "digest-previous", + "approvedPrograms": [], + }, + }); + std::fs::write( + details::installation_record_path(install_root, plugin_id), + serde_json::to_vec_pretty(&record).expect("record bytes"), + ) + .expect("installation record"); + package_root +} + +fn find_package<'a>(packages: &'a [super::ScannedPackage], id: &str) -> &'a super::ScannedPackage { + packages + .iter() + .find(|package| package.entry.plugin_id == id) + .expect("scanned package") +} + +#[test] +fn installed_details_use_core_review_and_recorded_current_and_previous_provenance() { + let root = TestDirectory::new("installed"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + let manifest = community_manifest(); + write_installed_package(&install_root, &manifest, false); + + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, "io.example.details-fixture"); + assert_eq!(package.entry.status, "accepted"); + let details = details::build_details(package, &install_root, &bundled_root) + .expect("package-backed details"); + + assert!( + !details.enabled, + "disabled state comes from the install record" + ); + assert_eq!(details.status, "accepted"); + assert!(details.localized_descriptions.is_empty()); + assert_eq!( + details.permissions, + ["languageServer.launch", "process.run", "process.test"] + ); + assert_eq!( + details.programs.len(), + 4, + "Core returns every complete program" + ); + assert!(details.programs.iter().all(Value::is_object)); + assert_eq!(details.versions.current.channel, "disk"); + assert_eq!( + details.versions.current.archive_sha256.as_deref(), + Some("archive-current") + ); + assert_eq!( + details.versions.current.verification.status, + "approvedDigest" + ); + let previous = details.versions.previous.expect("previous provenance"); + assert_eq!(previous.version, "0.0.9"); + assert_eq!(previous.channel, "marketplace"); + assert_eq!(previous.source_name.as_deref(), Some("Lithe Store")); + assert_eq!(previous.verification.status, "unknown"); + assert_eq!(details.versions.update_status, "unknown"); + assert!(details.operations.can_rollback); + assert!(details.features.iter().any(|feature| { + feature.kind == "languageServer" && feature.availability == "available" + })); + assert!(details + .features + .iter() + .any(|feature| { feature.kind == "run" && feature.availability == "declaredOnly" })); +} + +#[test] +fn bundled_details_report_the_verified_host_seal_not_a_manifest_claim() { + let root = TestDirectory::new("bundled"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + let mut manifest = fixture(); + manifest["id"] = json!("dev.lithe.plugin.php-support"); + manifest["vendor"]["signatureRequirement"] = json!("sameTeamAsHost"); + let package_root = bundled_root.join("php").join("0.1.0"); + std::fs::create_dir_all(&package_root).expect("bundled package"); + std::fs::write( + package_root.join("plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("manifest bytes"), + ) + .expect("manifest file"); + + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, "dev.lithe.plugin.php-support"); + let details = + details::build_details(package, &install_root, &bundled_root).expect("bundled details"); + assert_eq!(details.versions.current.origin, "bundled"); + assert_eq!(details.versions.current.verification.status, "hostSeal"); + assert!(!details.operations.can_uninstall); +} + +#[test] +fn invalid_installation_record_remains_explainable_and_quarantined() { + let root = TestDirectory::new("quarantine"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + let manifest = community_manifest(); + let plugin_id = manifest["id"].as_str().expect("plugin ID"); + let package_root = install_root.join(plugin_id).join("0.1.0"); + std::fs::create_dir_all(&package_root).expect("package root"); + std::fs::write( + package_root.join("plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("manifest bytes"), + ) + .expect("manifest file"); + std::fs::write( + details::installation_record_path(&install_root, plugin_id), + json!({ + "schemaVersion": 1, + "pluginId": plugin_id, + "version": "0.1.0", + "enabled": true, + }) + .to_string(), + ) + .expect("legacy record"); + + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, plugin_id); + let details = details::build_details(package, &install_root, &bundled_root) + .expect("quarantine details are readable"); + assert_eq!(details.status, "rejected"); + assert_eq!( + details.rejection.expect("rejection").code, + "plugin.manifest.invalid" + ); + assert_eq!(details.versions.current.verification.status, "rejected"); + assert!(!details.operations.can_set_enabled); + assert!(details.operations.can_uninstall); +} + +#[test] +fn detail_read_refuses_digest_changes_after_the_catalog_snapshot() { + let root = TestDirectory::new("changed"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + let manifest = community_manifest(); + let package_root = write_installed_package(&install_root, &manifest, true); + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, "io.example.details-fixture"); + std::fs::write(package_root.join("changed-after-scan.txt"), b"changed") + .expect("post-scan mutation"); + + let error: PluginDetailsError = details::build_details(package, &install_root, &bundled_root) + .expect_err("changed digest is refused"); + assert_eq!(error.code, "plugin.details.packageChanged"); +} + +#[test] +fn detail_read_refuses_an_installation_record_that_no_longer_owns_the_scanned_package() { + let root = TestDirectory::new("record-changed"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + let manifest = community_manifest(); + let plugin_id = manifest["id"].as_str().expect("plugin ID"); + write_installed_package(&install_root, &manifest, true); + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, plugin_id); + let record_path = details::installation_record_path(&install_root, plugin_id); + let original: Value = + serde_json::from_slice(&std::fs::read(&record_path).expect("record bytes")) + .expect("record JSON"); + + for (field, changed) in [ + ("schemaVersion", json!(1)), + ("pluginId", json!("io.example.different")), + ("version", json!("9.9.9")), + ("origin", json!("marketplace")), + ( + "disk", + json!({ "fileName": "changed.zip", "contentDigest": "changed", "approvedPrograms": [] }), + ), + ] { + let mut record = original.clone(); + record[field] = changed; + std::fs::write( + &record_path, + serde_json::to_vec(&record).expect("record bytes"), + ) + .expect("changed record"); + let error = details::build_details(package, &install_root, &bundled_root) + .expect_err("stale provenance must not be attached to another active package"); + assert_eq!( + error.code, "plugin.details.installationRecordChanged", + "changed {field}" + ); + } +} + +#[test] +fn detail_enabled_state_comes_from_the_live_installation_record() { + let root = TestDirectory::new("enabled-changed"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + let manifest = community_manifest(); + let plugin_id = manifest["id"].as_str().expect("plugin ID"); + write_installed_package(&install_root, &manifest, true); + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, plugin_id); + write_installed_package(&install_root, &manifest, false); + + let details = details::build_details(package, &install_root, &bundled_root) + .expect("same package with changed enabled state"); + assert!(!details.enabled); +} + +#[test] +fn details_reject_a_cached_package_outside_the_owning_store() { + let root = TestDirectory::new("boundary"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + let outside_root = root.0.join("outside"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + std::fs::create_dir_all(&install_root).expect("installed root"); + let manifest = community_manifest(); + write_installed_package(&outside_root, &manifest, true); + let packages = scan::build_catalog_from(&bundled_root, &outside_root).expect("catalog"); + let package = find_package(&packages, "io.example.details-fixture"); + + let error = details::build_details(package, &install_root, &bundled_root) + .expect_err("foreign store path is refused"); + assert_eq!(error.code, "plugin.details.pathBoundary"); +} + +#[test] +fn provenance_origin_is_not_inferred_from_manifest_vendor_fields() { + let root = TestDirectory::new("origin"); + let bundled_root = root.0.join("bundled"); + let install_root = root.0.join("installed"); + std::fs::create_dir_all(&bundled_root).expect("bundled root"); + let mut manifest = community_manifest(); + manifest["vendor"]["id"] = json!("dev.lithe"); + manifest["vendor"]["displayName"] = json!("Claims Official"); + write_installed_package(&install_root, &manifest, true); + + let packages = scan::build_catalog_from(&bundled_root, &install_root).expect("catalog"); + let package = find_package(&packages, "io.example.details-fixture"); + let details = + details::build_details(package, &install_root, &bundled_root).expect("community details"); + assert_eq!(details.versions.current.origin, "community"); + assert_eq!(details.versions.current.channel, "disk"); + assert_eq!( + details.versions.current.verification.status, + "approvedDigest" + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/marketplace_download.rs b/windows/tauri/src-tauri/src/plugin_host/marketplace_download.rs new file mode 100644 index 0000000000..9428748341 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/marketplace_download.rs @@ -0,0 +1,1421 @@ +//! Controlled downloads for declarative marketplace sources. +//! +//! The engine performs every HTTP round trip through an injected +//! [`HttpTransport`], so tests drive the real redirect, budget, deadline, and +//! cancellation logic against a local server while production keeps its +//! HTTPS-only policy. Redirects are resolved manually — a response never +//! silently drags the client to another origin, every hop is re-checked +//! against the fetch policy, and the hop count is bounded. A downloaded +//! artifact is written to a private `create_new` file under the caller's +//! runtime download root only after its SHA-256 matches the digest the +//! Core-validated index published: a completed download is a byte-exact +//! archive, never verified package content. + +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; +use tokio::sync::Notify; + +/// A cooperative cancellation handle shared with a download's owner. Once +/// cancelled, network waits wake immediately. Cancellation and final file/cache +/// publication share a lock: cancellation before that commit prevents publication; +/// once publication starts, its result belongs to the caller and is not revoked. +#[derive(Clone, Default)] +pub struct CancelToken(Arc); + +#[derive(Default)] +struct CancelState { + cancelled: AtomicBool, + notify: Notify, + publication: Mutex<()>, +} + +impl CancelToken { + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + let _publication = self + .0 + .publication + .lock() + .unwrap_or_else(|error| error.into_inner()); + self.0.cancelled.store(true, Ordering::SeqCst); + self.0.notify.notify_waiters(); + } + + pub fn is_cancelled(&self) -> bool { + self.0.cancelled.load(Ordering::SeqCst) + } + + async fn cancelled(&self) { + // Register before checking the flag, including when the future has not + // yet been polled by select. No cancellation can fall between the two. + let notified = self.0.notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if !self.is_cancelled() { + notified.await; + } + } + + pub(super) fn check(&self, deadline: Instant) -> Result<(), FetchFailure> { + if self.is_cancelled() { + Err(FetchFailure::Cancelled) + } else if Instant::now() >= deadline { + Err(FetchFailure::DeadlineExceeded) + } else { + Ok(()) + } + } + + pub(super) fn commit( + &self, + deadline: Instant, + publish: impl FnOnce() -> T, + ) -> Result { + let _publication = self + .0 + .publication + .lock() + .unwrap_or_else(|error| error.into_inner()); + self.check(deadline)?; + Ok(publish()) + } +} + +/// Competes every network wait with cancellation and the same absolute deadline. +async fn wait_for( + cancel: &CancelToken, + deadline: Instant, + operation: impl Future>, +) -> Result { + cancel.check(deadline)?; + tokio::select! { + biased; + _ = cancel.cancelled() => Err(FetchFailure::Cancelled), + _ = tokio::time::sleep_until(deadline.into()) => Err(FetchFailure::DeadlineExceeded), + result = operation => { + cancel.check(deadline)?; + result + } + } +} + +/// Owns a private runtime file until its atomic rename succeeds. Callers supply +/// only their writable state/download root; this never targets packaged resources. +/// The file handle closes before cleanup, including write/flush/rename failures. +pub(super) struct PendingFile { + path: PathBuf, + published: bool, +} + +impl PendingFile { + pub(super) fn write(path: PathBuf, bytes: &[u8]) -> std::io::Result { + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + let pending = Self { + path, + published: false, + }; + let result = (move || { + use std::io::Write; + let mut file = file; + file.write_all(bytes)?; + file.flush() + })(); + result?; + Ok(pending) + } + + pub(super) fn publish(self, target: &Path) -> std::io::Result<()> { + self.publish_with(target, |source, target| std::fs::rename(source, target)) + } + + pub(super) fn publish_with( + mut self, + target: &Path, + rename: impl FnOnce(&Path, &Path) -> std::io::Result<()>, + ) -> std::io::Result<()> { + rename(&self.path, target)?; + self.published = true; + Ok(()) + } +} + +impl Drop for PendingFile { + fn drop(&mut self) { + if !self.published { + let _ = std::fs::remove_file(&self.path); + } + } +} + +/// Upper bounds for one controlled fetch. Production call sites use +/// [`MAX_INDEX_JSON_BYTES`] for index documents and [`MAX_ARTIFACT_BYTES`] +/// for plugin archives; tests pass smaller budgets to exercise the boundary +/// without moving real payloads. +#[derive(Debug, Clone, Copy)] +pub struct FetchLimits { + /// Maximum accepted response body across one target, redirects included. + pub max_bytes: u64, + /// Maximum number of redirects followed for one target. + pub max_redirects: usize, +} + +/// Index documents beyond this size are refused instead of parsed. +pub const MAX_INDEX_JSON_BYTES: u64 = 8 * 1024 * 1024; +/// Plugin archives beyond this size are refused; the same budget the disk +/// install path enforces on decompressed content. +pub const MAX_ARTIFACT_BYTES: u64 = 128 * 1024 * 1024; +/// Redirect hops beyond this count are refused. +pub const MAX_REDIRECT_HOPS: usize = 5; + +/// Scheme policy for one fetch. Production fetches are HTTPS-only; the +/// loopback constructor is compiled for tests only so the real engine can be +/// exercised against a local HTTP server without ever weakening the shipped +/// policy. +#[derive(Debug, Clone, Copy)] +pub struct FetchPolicy { + loopback_http_allowed: bool, +} + +impl FetchPolicy { + /// The production policy: every request target and redirect hop must be + /// `https`. + pub fn https_only() -> Self { + Self { + loopback_http_allowed: false, + } + } + + #[cfg(test)] + pub fn loopback_http_for_tests() -> Self { + Self { + loopback_http_allowed: true, + } + } + + fn allows(&self, url: &url::Url) -> bool { + match url.scheme() { + "https" => true, + "http" if self.loopback_http_allowed => matches!( + url.host_str(), + Some("127.0.0.1") | Some("::1") | Some("localhost") + ), + _ => false, + } + } +} + +/// Every failure a controlled fetch can produce, with a stable kind the +/// marketplace surface can branch on without parsing messages. +#[derive(Debug, Clone, PartialEq)] +pub enum FetchFailure { + Cancelled, + DeadlineExceeded, + /// A request or redirect target was refused by the fetch policy. + DisallowedTarget { + url: String, + }, + /// A redirect response carried no usable `Location`. + RedirectWithoutLocation { + status: u16, + }, + TooManyRedirects { + hops: usize, + }, + BudgetExceeded { + limit: u64, + }, + /// The final response was not a success status. + HttpError { + status: u16, + url: String, + }, + /// The transport failed (connection error, truncated body, ...). + Transport(String), + /// The downloaded bytes do not match the published digest. + DigestMismatch { + expected: String, + }, +} + +impl FetchFailure { + /// Stable failure kind for the marketplace surface. + pub fn kind(&self) -> &'static str { + match self { + FetchFailure::Cancelled => "cancelled", + FetchFailure::DeadlineExceeded => "deadline", + FetchFailure::DisallowedTarget { .. } => "disallowedTarget", + FetchFailure::RedirectWithoutLocation { .. } => "redirectWithoutLocation", + FetchFailure::TooManyRedirects { .. } => "tooManyRedirects", + FetchFailure::BudgetExceeded { .. } => "budgetExceeded", + FetchFailure::HttpError { .. } => "httpError", + FetchFailure::Transport(_) => "transport", + FetchFailure::DigestMismatch { .. } => "digestMismatch", + } + } + + pub fn message(&self) -> String { + match self { + FetchFailure::Cancelled => "the fetch was cancelled".to_string(), + FetchFailure::DeadlineExceeded => "the fetch exceeded its deadline".to_string(), + FetchFailure::DisallowedTarget { url } => { + format!("the fetch target {url} is not allowed by the source policy") + } + FetchFailure::RedirectWithoutLocation { status } => { + format!("the redirect response {status} carries no Location header") + } + FetchFailure::TooManyRedirects { hops } => { + format!("the fetch exceeded {hops} redirects") + } + FetchFailure::BudgetExceeded { limit } => { + format!("the response exceeds the {limit} byte budget") + } + FetchFailure::HttpError { status, url } => { + format!("the fetch of {url} returned HTTP {status}") + } + FetchFailure::Transport(error) => format!("the fetch failed: {error}"), + FetchFailure::DigestMismatch { expected } => { + format!("the downloaded archive does not match the published digest {expected}") + } + } + } +} + +/// One HTTP response as the engine sees it: status, lowercased headers, and +/// the body capped at the transport's budget. +#[derive(Debug, Clone)] +pub struct HttpResponse { + pub status: u16, + pub headers: HashMap, + pub body: Vec, +} + +impl HttpResponse { + fn header(&self, name: &str) -> Option<&str> { + self.headers + .get(&name.to_ascii_lowercase()) + .map(String::as_str) + } +} + +/// Performs one HTTP GET. Implementations enforce the body budget while the +/// response streams (a body that grows past `max_bytes` fails with +/// [`FetchFailure::BudgetExceeded`]) and respect the deadline and cancel +/// token during every network wait. Redirect handling stays with the engine, so a +/// 3xx response's body is buffered but ignored. +pub trait HttpTransport: Send + Sync { + fn get<'a>( + &'a self, + url: &'a str, + max_bytes: u64, + deadline: Instant, + cancel: &'a CancelToken, + ) -> Pin> + Send + 'a>>; +} + +/// The production transport: the shared `tauri_plugin_http` reqwest client +/// with redirects disabled (the engine resolves every hop itself), a per +/// request timeout bounded by the remaining deadline, and streaming chunk +/// reads that stop at the budget. +#[derive(Clone)] +pub struct ReqwestTransport { + client: tauri_plugin_http::reqwest::Client, + #[cfg(test)] + on_chunk: Option>, +} + +impl ReqwestTransport { + /// Builds the shared client. Redirects are never followed automatically: + /// every hop must pass the fetch policy first. + pub fn new() -> Result { + let client = tauri_plugin_http::reqwest::Client::builder() + .redirect(tauri_plugin_http::reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("the marketplace HTTP client could not be built: {error}"))?; + Ok(Self { + client, + #[cfg(test)] + on_chunk: None, + }) + } +} + +impl Default for ReqwestTransport { + fn default() -> Self { + Self::new().expect("the marketplace HTTP client builds") + } +} + +impl HttpTransport for ReqwestTransport { + fn get<'a>( + &'a self, + url: &'a str, + max_bytes: u64, + deadline: Instant, + cancel: &'a CancelToken, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(FetchFailure::DeadlineExceeded); + } + let response = wait_for(cancel, deadline, async { + self.client + .get(url) + .timeout(remaining) + .send() + .await + .map_err(transport_error) + }) + .await?; + let status = response.status().as_u16(); + let headers = response + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_ascii_lowercase(), + value.to_str().unwrap_or_default().to_string(), + ) + }) + .collect::>(); + let mut body = Vec::new(); + let mut response = response; + while let Some(chunk) = wait_for(cancel, deadline, async { + response.chunk().await.map_err(transport_error) + }) + .await? + { + if body.len() as u64 + chunk.len() as u64 > max_bytes { + return Err(FetchFailure::BudgetExceeded { limit: max_bytes }); + } + body.extend_from_slice(&chunk); + #[cfg(test)] + if let Some(on_chunk) = &self.on_chunk { + on_chunk(body.len()); + } + } + Ok(HttpResponse { + status, + headers, + body, + }) + }) + } +} + +/// One completed controlled fetch: the body, the URL it was finally served +/// from, and how many redirect hops were followed. +#[derive(Debug, Clone)] +pub struct FetchOutcome { + pub body: Vec, + pub final_url: String, + pub hops: usize, +} + +/// Follows at most `limits.max_redirects` hops from `initial_url`, checking +/// every target (the initial URL included) against the fetch policy and the +/// embedded-credential rule, the deadline, and the cancel token. The body is +/// capped at `limits.max_bytes`. +pub async fn fetch_controlled( + transport: &dyn HttpTransport, + initial_url: &str, + limits: FetchLimits, + policy: FetchPolicy, + cancel: &CancelToken, + deadline: Instant, +) -> Result { + let mut url = initial_url.to_string(); + let mut hops = 0usize; + loop { + cancel.check(deadline)?; + check_target(&url, policy)?; + let response = wait_for( + cancel, + deadline, + transport.get(&url, limits.max_bytes, deadline, cancel), + ) + .await?; + if (300..400).contains(&response.status) { + if hops >= limits.max_redirects { + return Err(FetchFailure::TooManyRedirects { hops }); + } + let location = + response + .header("location") + .ok_or(FetchFailure::RedirectWithoutLocation { + status: response.status, + })?; + let target = resolved_target(&url, location)?; + check_target(&target, policy)?; + url = target; + hops += 1; + continue; + } + if !(200..300).contains(&response.status) { + return Err(FetchFailure::HttpError { + status: response.status, + url, + }); + } + return Ok(FetchOutcome { + body: response.body, + final_url: url, + hops, + }); + } +} + +/// A downloaded, digest-bound artifact waiting for the install flow. +#[derive(Debug, Clone, PartialEq)] +pub struct DownloadedArtifact { + /// Private file under the download root; consumed by the install + /// transaction or removed with it. + pub path: PathBuf, + /// Lowercase hex SHA-256 of the file bytes. + pub sha256: String, + pub final_url: String, + pub bytes: u64, +} + +/// Downloads one artifact and binds it to `expected_sha256` +/// (case-insensitive hex) before writing it to a private `create_new` file +/// under `download_root`. A digest mismatch, any failure, or cancellation +/// leaves no file behind; the returned archive is byte-exact published +/// content, not verified package content. +pub async fn download_artifact( + download_root: &Path, + transport: &dyn HttpTransport, + url: &str, + expected_sha256: &str, + limits: FetchLimits, + policy: FetchPolicy, + cancel: &CancelToken, + deadline: Instant, +) -> Result { + let outcome = fetch_controlled(transport, url, limits, policy, cancel, deadline).await?; + cancel.check(deadline)?; + let digest = format!("{:x}", Sha256::digest(&outcome.body)); + cancel.check(deadline)?; + if !digest.eq_ignore_ascii_case(expected_sha256) { + return Err(FetchFailure::DigestMismatch { + expected: expected_sha256.to_string(), + }); + } + std::fs::create_dir_all(download_root).map_err(|error| { + FetchFailure::Transport(format!("the download root is unusable: {error}")) + })?; + let path = download_root.join(format!( + "marketplace-download-{}-{}.zip", + std::process::id(), + unique_suffix() + )); + let pending = PendingFile::write(path.with_extension("zip.tmp"), &outcome.body) + .map_err(|error| FetchFailure::Transport(format!("the download file failed: {error}")))?; + // The token's commit lock is held only for publication, never for hashing, + // writing or flushing. A losing cancellation drops the pending-file owner. + cancel + .commit(deadline, || pending.publish(&path))? + .map_err(|error| { + FetchFailure::Transport(format!("the download publish failed: {error}")) + })?; + Ok(DownloadedArtifact { + path, + sha256: digest, + final_url: outcome.final_url, + bytes: outcome.body.len() as u64, + }) +} + +/// Checks one request target against the fetch policy and the +/// embedded-credential rule, mirroring the rule the AI commit client applies +/// to redirect targets. +fn check_target(url: &str, policy: FetchPolicy) -> Result<(), FetchFailure> { + let target = url::Url::parse(url).map_err(|error| FetchFailure::DisallowedTarget { + url: format!("{url} ({error})"), + })?; + if !policy.allows(&target) || !target.username().is_empty() || target.password().is_some() { + return Err(FetchFailure::DisallowedTarget { + url: target.to_string(), + }); + } + Ok(()) +} + +/// Resolves a `Location` header against the current target. +fn resolved_target(current: &str, location: &str) -> Result { + let base = url::Url::parse(current).map_err(|error| FetchFailure::DisallowedTarget { + url: format!("{current} ({error})"), + })?; + let target = base + .join(location) + .map_err(|error| FetchFailure::DisallowedTarget { + url: format!("{location} ({error})"), + })?; + Ok(target.to_string()) +} + +/// Flattens a reqwest error with its whole source chain: the top message +/// alone ("error sending request") hides whether the connection was refused, +/// reset, or proxied away. +fn transport_error(error: tauri_plugin_http::reqwest::Error) -> FetchFailure { + if error.is_timeout() { + return FetchFailure::DeadlineExceeded; + } + let mut message = error.to_string(); + let mut cause = std::error::Error::source(&error); + while let Some(error) = cause { + message.push_str(&format!(": {error}")); + cause = error.source(); + } + FetchFailure::Transport(message) +} + +/// Time- and process-derived uniqueness for private file names. +fn unique_suffix() -> String { + static SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + format!( + "{}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + SEQUENCE.fetch_add(1, Ordering::SeqCst) + ) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::io::{Read, Write}; + use std::time::Duration; + + /// One scripted response of the local test server. `content_length` + /// overrides the advertised Content-Length and `body_prefix` limits how + /// many bytes are actually written, which is how the tests model a + /// truncated download. + #[derive(Clone)] + pub(crate) struct TestResponse { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec, + pub content_length: Option, + pub body_prefix: Option, + pub pause: Option<(Arc, PauseAt)>, + } + + impl TestResponse { + pub fn ok(body: Vec) -> Self { + Self { + status: 200, + headers: Vec::new(), + body, + content_length: None, + body_prefix: None, + pause: None, + } + } + + pub fn redirect(location: &str) -> Self { + Self { + status: 302, + headers: vec![("Location".to_string(), location.to_string())], + body: Vec::new(), + content_length: None, + body_prefix: None, + pause: None, + } + } + + pub fn paused(body: Vec, phase: PauseAt) -> (Self, Arc) { + let gate = TestGate::new(); + let mut response = Self::ok(body); + response.pause = Some((gate.clone(), phase)); + (response, gate) + } + + pub fn with_status(status: u16) -> Self { + Self { + status, + ..Self::ok(Vec::new()) + } + } + } + + /// A gated response can stop before headers or after its final body bytes. + /// The server owner releases every gate during teardown, including panic paths. + #[derive(Clone, Copy)] + pub(crate) enum PauseAt { + Headers, + Eof, + } + + pub(crate) struct TestGate { + entered: Notify, + released: Mutex, + wake: std::sync::Condvar, + } + + impl TestGate { + fn new() -> Arc { + Arc::new(Self { + entered: Notify::new(), + released: Mutex::new(false), + wake: std::sync::Condvar::new(), + }) + } + + pub async fn entered(&self) { + tokio::time::timeout(Duration::from_secs(2), self.entered.notified()) + .await + .expect("server reaches the controlled boundary"); + } + + fn wait(&self, deadline: Instant) { + self.entered.notify_one(); + let released = self + .released + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _ = self + .wake + .wait_timeout_while( + released, + deadline.saturating_duration_since(Instant::now()), + |released| !*released, + ) + .expect("gate wait"); + } + + pub fn release(&self) { + *self + .released + .lock() + .unwrap_or_else(|error| error.into_inner()) = true; + self.wake.notify_all(); + } + } + + /// One blocking owner serves connections serially; there are no detached + /// connection threads or global locks. Drop releases gates, shuts down the + /// active socket and wakes accept, then joins only after a bounded done signal. + pub(crate) struct TestHttpServer { + pub base_url: String, + address: std::net::SocketAddr, + shutdown: Arc, + active: Arc>>, + accepted: Arc, + gates: Vec>, + done: std::sync::mpsc::Receiver<()>, + thread: Option>, + } + + impl TestHttpServer { + pub fn start(routes: Vec<(&'static str, Vec)>) -> Self { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("test listener binds"); + let address = listener.local_addr().expect("addr"); + let base_url = format!("http://{address}"); + let shutdown = Arc::new(AtomicBool::new(false)); + let active = Arc::new(Mutex::new(None)); + let accepted = Arc::new(Notify::new()); + let thread_accepted = accepted.clone(); + let gates = routes + .iter() + .flat_map(|(_, responses)| responses.iter()) + .filter_map(|response| response.pause.as_ref().map(|(gate, _)| gate.clone())) + .collect(); + let thread_shutdown = shutdown.clone(); + let thread_active = active.clone(); + let (done_tx, done) = std::sync::mpsc::channel(); + let thread = std::thread::spawn(move || { + // Signal even if serving panics, so the owner can join the failure. + struct Done(std::sync::mpsc::Sender<()>); + impl Drop for Done { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + let _done = Done(done_tx); + let mut routes = routes; + while !thread_shutdown.load(Ordering::SeqCst) { + let Ok((stream, _)) = listener.accept() else { + break; + }; + *thread_active.lock().expect("active socket") = + Some(stream.try_clone().expect("socket clone")); + thread_accepted.notify_one(); + if !thread_shutdown.load(Ordering::SeqCst) { + serve_connection(stream, &mut routes, &thread_shutdown); + } + thread_active.lock().expect("active socket").take(); + } + }); + Self { + base_url, + address, + shutdown, + active, + accepted, + gates, + done, + thread: Some(thread), + } + } + } + + impl Drop for TestHttpServer { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::SeqCst); + for gate in &self.gates { + gate.release(); + } + if let Some(stream) = self + .active + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + { + let _ = stream.shutdown(std::net::Shutdown::Both); + } + let _ = std::net::TcpStream::connect_timeout(&self.address, Duration::from_millis(200)); + if self.done.recv_timeout(Duration::from_secs(2)).is_err() { + // A native thread cannot be killed safely. The timing harness owns + // this test process: fail it instead of hanging or detaching work. + eprintln!("marketplace test server failed to stop within its cleanup deadline"); + std::process::abort(); + } + if let Some(thread) = self.thread.take() { + let result = thread.join(); + if !std::thread::panicking() { + result.expect("server thread joins"); + } + } + } + } + + fn write_bounded( + stream: &mut std::net::TcpStream, + mut bytes: &[u8], + stop: &AtomicBool, + deadline: Instant, + ) -> std::io::Result<()> { + while !bytes.is_empty() { + if stop.load(Ordering::SeqCst) || Instant::now() >= deadline { + return Err(std::io::ErrorKind::TimedOut.into()); + } + stream.set_write_timeout(Some(io_wait_bound(deadline)))?; + let written = match stream.write(bytes) { + Ok(written) => written, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + continue + } + Err(error) => return Err(error), + }; + if written == 0 { + return Err(std::io::ErrorKind::WriteZero.into()); + } + bytes = &bytes[written..]; + } + Ok(()) + } + + fn io_wait_bound(deadline: Instant) -> Duration { + // Windows may leave a synchronous read pending after another socket + // handle calls shutdown. Bound that OS wait too, so the owner stop flag + // is observed without relying on interrupting the pending syscall. + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(100)) + } + + fn serve_connection( + mut stream: std::net::TcpStream, + routes: &mut [(&'static str, Vec)], + stop: &AtomicBool, + ) { + let deadline = Instant::now() + Duration::from_secs(2); + let mut buffer = Vec::new(); + let mut chunk = [0u8; 1024]; + while !stop.load(Ordering::SeqCst) && Instant::now() < deadline { + if stream + .set_read_timeout(Some(io_wait_bound(deadline))) + .is_err() + { + return; + } + match stream.read(&mut chunk) { + Ok(0) => return, + Ok(read) => { + buffer.extend_from_slice(&chunk[..read]); + if buffer.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + if buffer.len() > 16 * 1024 { + return; + } + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + continue + } + Err(_) => return, + } + } + let request = String::from_utf8_lossy(&buffer); + let path = request.split_whitespace().nth(1).unwrap_or_default(); + let response = routes + .iter_mut() + .find(|(route, _)| path == *route || path.starts_with(&format!("{route}?"))) + .map(|(_, responses)| { + if responses.len() > 1 { + responses.remove(0) + } else { + responses[0].clone() + } + }) + .unwrap_or_else(|| TestResponse::with_status(404)); + if let Some((gate, PauseAt::Headers)) = &response.pause { + gate.wait(deadline); + } + // A close-delimited body lets tests park after the final chunk but before + // EOF, without lying about Content-Length or relying on socket timing. + let length = if matches!(&response.pause, Some((_, PauseAt::Eof))) { + String::new() + } else { + format!( + "Content-Length: {}\r\n", + response.content_length.unwrap_or(response.body.len()) + ) + }; + let mut head = format!( + "HTTP/1.1 {} OK\r\n{length}Connection: close\r\n", + response.status + ); + for (name, value) in &response.headers { + head.push_str(&format!("{name}: {value}\r\n")); + } + head.push_str("\r\n"); + let written = response + .body_prefix + .unwrap_or(response.body.len()) + .min(response.body.len()); + if write_bounded(&mut stream, head.as_bytes(), stop, deadline).is_err() { + return; + } + if write_bounded(&mut stream, &response.body[..written], stop, deadline).is_err() { + return; + } + if let Some((gate, PauseAt::Eof)) = &response.pause { + gate.wait(deadline); + } + } + + /// Every test owns its private directory even when an assertion unwinds. + pub(crate) struct TestRoot(pub PathBuf); + + impl TestRoot { + pub fn new(label: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "lithe-marketplace-{label}-{}-{}", + std::process::id(), + unique_suffix() + )); + std::fs::create_dir_all(&root).expect("test runtime root"); + Self(root) + } + } + + impl Drop for TestRoot { + fn drop(&mut self) { + let result = std::fs::remove_dir_all(&self.0); + if !std::thread::panicking() { + result.expect("test runtime root is removed"); + } + } + } + + #[tokio::test] + async fn cancellation_wakes_registered_waiters_and_remains_sticky() { + let cancel = CancelToken::new(); + let first = cancel.cancelled(); + let second = cancel.cancelled(); + tokio::pin!(first, second); + tokio::select! { + biased; + _ = &mut first => panic!("not cancelled yet"), + _ = &mut second => panic!("not cancelled yet"), + _ = std::future::ready(()) => {} + } + cancel.cancel(); + tokio::time::timeout(Duration::from_secs(2), async { + tokio::join!(&mut first, &mut second); + cancel.cancelled().await; + }) + .await + .expect("all waiters wake, including a later registration"); + } + + #[tokio::test] + async fn cancellation_interrupts_an_in_flight_request_before_headers() { + let (response, gate) = TestResponse::paused(b"archive".to_vec(), PauseAt::Headers); + let server = TestHttpServer::start(vec![("/pending", vec![response])]); + let transport = ReqwestTransport::new().expect("transport"); + let cancel = CancelToken::new(); + let url = format!("{}/pending", server.base_url); + let fetch = fetch_controlled( + &transport, + &url, + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &cancel, + test_deadline(), + ); + tokio::pin!(fetch); + tokio::select! { + result = &mut fetch => panic!("request must still be waiting: {result:?}"), + _ = gate.entered() => {} + } + cancel.cancel(); + let result = tokio::time::timeout(Duration::from_secs(1), &mut fetch) + .await + .expect("cancel wakes send before the network deadline"); + assert_eq!( + result.expect_err("request cancelled"), + FetchFailure::Cancelled + ); + // The gate remains closed until the owner's Drop. Cancellation must not + // require the server to send headers or the request deadline to expire. + } + + #[tokio::test] + async fn cancelling_after_the_final_chunk_before_eof_never_publishes_an_artifact() { + let root = TestRoot::new("cancel-eof"); + let (response, _gate) = TestResponse::paused(b"archive".to_vec(), PauseAt::Eof); + let server = TestHttpServer::start(vec![("/pending", vec![response])]); + let consumed = Arc::new(Notify::new()); + let mut transport = ReqwestTransport::new().expect("transport"); + let notify = consumed.clone(); + transport.on_chunk = Some(Arc::new(move |bytes| { + if bytes == b"archive".len() { + notify.notify_one(); + } + })); + let cancel = CancelToken::new(); + let url = format!("{}/pending", server.base_url); + let digest = format!("{:x}", Sha256::digest(b"archive")); + let download = download_artifact( + &root.0, + &transport, + &url, + &digest, + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &cancel, + test_deadline(), + ); + tokio::pin!(download); + tokio::select! { + result = &mut download => panic!("EOF is still gated: {result:?}"), + result = tokio::time::timeout(Duration::from_secs(1), consumed.notified()) => { + result.expect("the real client consumes the last body chunk"); + } + } + // This signal is emitted by the real client after appending the last + // body chunk, so the next network wait is specifically for EOF. + cancel.cancel(); + let result = tokio::time::timeout(Duration::from_secs(1), &mut download) + .await + .expect("cancel wakes the EOF wait"); + assert_eq!( + result.expect_err("download cancelled"), + FetchFailure::Cancelled + ); + assert_eq!(std::fs::read_dir(&root.0).expect("root").count(), 0); + } + + #[test] + fn cancellation_before_publication_removes_a_fully_written_private_file() { + let root = TestRoot::new("cancel-publication"); + let target = root.0.join("artifact.zip"); + let pending = + PendingFile::write(root.0.join("artifact.tmp"), b"flushed bytes").expect("stage"); + let cancel = CancelToken::new(); + cancel.cancel(); + let result = cancel.commit(test_deadline(), || pending.publish(&target)); + assert_eq!( + result.expect_err("cancel wins commit"), + FetchFailure::Cancelled + ); + assert_eq!(std::fs::read_dir(&root.0).expect("root").count(), 0); + } + + #[tokio::test] + async fn server_owner_interrupts_a_connection_still_reading_request_headers() { + let server = TestHttpServer::start(vec![]); + let mut connection = std::net::TcpStream::connect(server.address).expect("connect"); + tokio::time::timeout(Duration::from_secs(1), server.accepted.notified()) + .await + .expect("connection is owned before shutdown"); + drop(server); + assert_connection_closed(&mut connection); + } + + #[tokio::test] + async fn unwinding_releases_response_gates_threads_and_private_roots() { + let root = TestRoot::new("unwind"); + let path = root.0.clone(); + let (response, gate) = TestResponse::paused(b"archive".to_vec(), PauseAt::Headers); + let server = TestHttpServer::start(vec![("/", vec![response])]); + let mut connection = std::net::TcpStream::connect(server.address).expect("connect"); + connection + .set_write_timeout(Some(Duration::from_secs(1))) + .expect("bounded write"); + connection + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .expect("request"); + gate.entered().await; + let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let _root = root; + let _server = server; + panic!("simulated assertion failure"); + })); + assert!(unwind.is_err()); + assert!( + !path.exists(), + "RAII removes the root even during an assertion unwind" + ); + assert_connection_closed(&mut connection); + } + + fn assert_connection_closed(connection: &mut std::net::TcpStream) { + use std::io::ErrorKind; + connection + .set_read_timeout(Some(Duration::from_secs(1))) + .expect("bounded read"); + match connection.read(&mut [0]) { + Ok(0) => {} + Err(error) + if matches!( + error.kind(), + ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::BrokenPipe + | ErrorKind::UnexpectedEof + ) => {} + result => panic!("owner must close the socket, not merely time out: {result:?}"), + } + } + + fn test_limits(max_bytes: u64) -> FetchLimits { + FetchLimits { + max_bytes, + max_redirects: MAX_REDIRECT_HOPS, + } + } + + fn test_deadline() -> Instant { + Instant::now() + Duration::from_secs(10) + } + + #[tokio::test] + async fn fetch_serves_the_body_and_reports_the_final_url() { + let server = TestHttpServer::start(vec![( + "/index.json", + vec![TestResponse::ok(br#"{"ok": true}"#.to_vec())], + )]); + let transport = ReqwestTransport::new().expect("transport"); + let outcome = fetch_controlled( + &transport, + &format!("{}/index.json", server.base_url), + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect("the fetch succeeds"); + assert_eq!(outcome.body, br#"{"ok": true}"#.to_vec()); + assert_eq!(outcome.hops, 0); + assert_eq!(outcome.final_url, format!("{}/index.json", server.base_url)); + } + + #[tokio::test] + async fn redirects_are_followed_within_the_policy_and_reported() { + let server = TestHttpServer::start(vec![ + ("/start", vec![TestResponse::redirect("/final")]), + ("/final", vec![TestResponse::ok(b"final body".to_vec())]), + ]); + let transport = ReqwestTransport::new().expect("transport"); + let outcome = fetch_controlled( + &transport, + &format!("{}/start", server.base_url), + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect("the redirect chain succeeds"); + assert_eq!(outcome.body, b"final body".to_vec()); + assert_eq!(outcome.hops, 1); + assert_eq!(outcome.final_url, format!("{}/final", server.base_url)); + } + + #[tokio::test] + async fn insecure_and_credential_targets_are_refused() { + // The production policy refuses every non-HTTPS target outright. + let error = fetch_controlled( + &ReqwestTransport::new().expect("transport"), + "http://127.0.0.1:9/none", + test_limits(1024), + FetchPolicy::https_only(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("plain http is refused in production"); + assert_eq!(error.kind(), "disallowedTarget"); + + let server = TestHttpServer::start(vec![ + ( + "/to-insecure", + vec![TestResponse::redirect("http://evil.example.com/x")], + ), + ( + "/to-credentials", + vec![TestResponse::redirect("https://user:pass@example.com/x")], + ), + ]); + let transport = ReqwestTransport::new().expect("transport"); + for path in ["/to-insecure", "/to-credentials"] { + let error = fetch_controlled( + &transport, + &format!("{}{path}", server.base_url), + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("the disallowed redirect target must be refused"); + assert_eq!(error.kind(), "disallowedTarget", "{error:?}"); + } + } + + #[tokio::test] + async fn too_many_redirects_is_bounded() { + let server = TestHttpServer::start(vec![("/loop", vec![TestResponse::redirect("/loop")])]); + let transport = ReqwestTransport::new().expect("transport"); + let error = fetch_controlled( + &transport, + &format!("{}/loop", server.base_url), + FetchLimits { + max_bytes: 1024, + max_redirects: 3, + }, + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("the redirect loop must be bounded"); + assert_eq!(error, FetchFailure::TooManyRedirects { hops: 3 }); + } + + #[tokio::test] + async fn budget_exceeded_aborts_mid_body() { + let server = + TestHttpServer::start(vec![("/big", vec![TestResponse::ok(vec![b'x'; 10_000])])]); + let transport = ReqwestTransport::new().expect("transport"); + let error = fetch_controlled( + &transport, + &format!("{}/big", server.base_url), + test_limits(100), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("the oversized body must abort"); + assert_eq!(error, FetchFailure::BudgetExceeded { limit: 100 }); + } + + #[tokio::test] + async fn truncated_body_is_a_transport_error() { + let server = TestHttpServer::start(vec![( + "/truncated", + vec![TestResponse { + status: 200, + headers: Vec::new(), + body: vec![b'y'; 1_000], + content_length: Some(1_000), + body_prefix: Some(100), + pause: None, + }], + )]); + let transport = ReqwestTransport::new().expect("transport"); + let error = fetch_controlled( + &transport, + &format!("{}/truncated", server.base_url), + test_limits(10_000), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("the truncated body must be a transport error"); + assert_eq!(error.kind(), "transport", "{error:?}"); + } + + #[tokio::test] + async fn deadline_and_cancel_are_honored_before_any_request() { + let transport = ReqwestTransport::new().expect("transport"); + let error = fetch_controlled( + &transport, + "https://example.com/index.json", + test_limits(1024), + FetchPolicy::https_only(), + &CancelToken::new(), + Instant::now() - Duration::from_secs(1), + ) + .await + .expect_err("an expired deadline must fail immediately"); + assert_eq!(error, FetchFailure::DeadlineExceeded); + + let cancel = CancelToken::new(); + cancel.cancel(); + let error = fetch_controlled( + &transport, + "https://example.com/index.json", + test_limits(1024), + FetchPolicy::https_only(), + &cancel, + test_deadline(), + ) + .await + .expect_err("a cancelled fetch must fail immediately"); + assert_eq!(error, FetchFailure::Cancelled); + } + + #[tokio::test] + async fn http_error_status_is_reported() { + let server = + TestHttpServer::start(vec![("/missing", vec![TestResponse::with_status(404)])]); + let transport = ReqwestTransport::new().expect("transport"); + let error = fetch_controlled( + &transport, + &format!("{}/missing", server.base_url), + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("a 404 must be reported"); + assert_eq!( + error, + FetchFailure::HttpError { + status: 404, + url: format!("{}/missing", server.base_url), + } + ); + } + + #[tokio::test] + async fn download_artifact_binds_the_digest_and_cleans_up_on_mismatch() { + let server = TestHttpServer::start(vec![( + "/plugin.zip", + vec![TestResponse::ok(b"archive bytes".to_vec())], + )]); + let transport = ReqwestTransport::new().expect("transport"); + let root = TestRoot::new("download-ok"); + let download_root = &root.0; + let digest = format!("{:x}", Sha256::digest(b"archive bytes")); + let artifact = download_artifact( + &download_root, + &transport, + &format!("{}/plugin.zip", server.base_url), + &digest, + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect("the digest-matched download succeeds"); + assert_eq!( + std::fs::read(&artifact.path).expect("artifact bytes"), + b"archive bytes".to_vec() + ); + assert_eq!(artifact.sha256, digest); + assert_eq!(artifact.bytes, 13); + assert!(artifact.path.starts_with(&download_root)); + + // A digest mismatch leaves the private root with only the matched + // download. + let error = download_artifact( + &download_root, + &transport, + &format!("{}/plugin.zip", server.base_url), + &"0".repeat(64), + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect_err("the digest mismatch must be refused"); + assert_eq!( + error, + FetchFailure::DigestMismatch { + expected: "0".repeat(64), + } + ); + let leftovers: Vec<_> = std::fs::read_dir(&download_root) + .expect("root readable") + .flatten() + .map(|entry| entry.file_name().to_string_lossy().to_string()) + .collect(); + assert_eq!( + leftovers.len(), + 1, + "only the matched download remains: {leftovers:?}" + ); + + // A second successful download never reuses a file name. + let second = download_artifact( + &download_root, + &transport, + &format!("{}/plugin.zip", server.base_url), + &digest, + test_limits(1024), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + test_deadline(), + ) + .await + .expect("the second download succeeds"); + assert_ne!(second.path, artifact.path); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs b/windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs new file mode 100644 index 0000000000..77e9cc2f11 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/marketplace_index.rs @@ -0,0 +1,1175 @@ +//! Marketplace index refresh and the last-known-good cache. +//! +//! Index URLs and index legality are Core semantics: the caller hands in a +//! source identity whose `indexURL` already went through +//! `plugins.marketplace.resolveSource`, and every fetched document goes +//! through `plugins.marketplace.validateIndex` with this host's platform and +//! capability declaration before it may become the cached answer. A refresh +//! that fails anywhere — transport, budget, deadline, cancellation, invalid +//! JSON, Core rejection — never overwrites the last-known-good index, and a +//! refresh that started after a newer one never overwrites that newer one's +//! cache either. The cache keeps the validated raw input, and offline reads +//! revalidate it with the running Core and current host capabilities. Availability +//! statuses come only from Core: a legal index says nothing about whether +//! an archive or its signature was verified. + +use super::marketplace_download::{ + fetch_controlled, CancelToken, FetchFailure, FetchLimits, FetchPolicy, HttpTransport, + PendingFile, MAX_INDEX_JSON_BYTES, MAX_REDIRECT_HOPS, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// Who maintains a source, in Core's `sourceKind` vocabulary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SourceKind { + Official, + ThirdParty, +} + +/// One refresh request for an already-resolved source. The `index_url` comes +/// from Core's `resolveSource` outcome; this layer never parses user URLs. +#[derive(Debug, Clone)] +pub struct IndexRefreshRequest { + /// Stable source identity from the host's source management; used as the + /// cache key and validated as a path component. + pub source_id: String, + pub index_url: String, + pub source_kind: SourceKind, + /// This host's platform identifier, e.g. `windows-x64`. + pub platform: String, + /// This host's capability declaration, as Core expects it. + pub host_capabilities: Value, + /// Overall deadline for the fetch, including redirects. + pub deadline: Duration, +} + +/// The outcome of one successful refresh against the live source. +#[derive(Debug, Clone)] +pub struct RefreshedIndex { + pub source_id: String, + pub platform: String, + /// The Core-validated index (`{plugins, rejected}`) for this platform. + pub index: Value, + /// URL the document was finally served from. + pub final_url: String, + /// Whether this refresh became the cached last-known-good answer. An + /// older request that finishes after a newer one reports `false`. + pub persisted: bool, +} + +/// Every way a refresh can fail, with a stable kind for the marketplace +/// surface. None of these ever touch the cached last-known-good index. +#[derive(Debug, Clone, PartialEq)] +pub enum IndexRefreshFailure { + Cancelled, + DeadlineExceeded, + Transport(String), + BudgetExceeded, + InvalidJson(String), + /// Core rejected the whole index; the code is Core's stable code. + Rejected { + code: String, + message: String, + }, + /// The index URL answered with a non-success status. + HttpError { + status: u16, + url: String, + }, + /// Core itself failed (malformed envelope, host bug). + Core(String), + CacheWrite(String), +} + +impl IndexRefreshFailure { + /// Stable failure kind for the marketplace surface. + pub fn kind(&self) -> &'static str { + match self { + IndexRefreshFailure::Cancelled => "cancelled", + IndexRefreshFailure::DeadlineExceeded => "deadline", + IndexRefreshFailure::Transport(_) => "transport", + IndexRefreshFailure::BudgetExceeded => "budgetExceeded", + IndexRefreshFailure::InvalidJson(_) => "invalidJson", + IndexRefreshFailure::HttpError { .. } => "httpError", + IndexRefreshFailure::Rejected { .. } => "indexRejected", + IndexRefreshFailure::Core(_) => "coreFailure", + IndexRefreshFailure::CacheWrite(_) => "cacheWrite", + } + } + + pub fn message(&self) -> String { + match self { + IndexRefreshFailure::Cancelled => "the index refresh was cancelled".to_string(), + IndexRefreshFailure::DeadlineExceeded => { + "the index refresh exceeded its deadline".to_string() + } + IndexRefreshFailure::Transport(error) => format!("the index fetch failed: {error}"), + IndexRefreshFailure::BudgetExceeded => { + format!("the index exceeds the {MAX_INDEX_JSON_BYTES} byte budget") + } + IndexRefreshFailure::InvalidJson(error) => { + format!("the index is not valid JSON: {error}") + } + IndexRefreshFailure::HttpError { status, url } => { + format!("the index fetch of {url} returned HTTP {status}") + } + IndexRefreshFailure::Rejected { message, .. } => message.clone(), + IndexRefreshFailure::Core(error) => error.clone(), + IndexRefreshFailure::CacheWrite(error) => { + format!("the index cache could not be written: {error}") + } + } + } +} + +impl From for IndexRefreshFailure { + fn from(failure: FetchFailure) -> Self { + match failure { + FetchFailure::Cancelled => IndexRefreshFailure::Cancelled, + FetchFailure::DeadlineExceeded => IndexRefreshFailure::DeadlineExceeded, + FetchFailure::BudgetExceeded { .. } => IndexRefreshFailure::BudgetExceeded, + FetchFailure::HttpError { status, url } => { + IndexRefreshFailure::HttpError { status, url } + } + FetchFailure::Transport(error) => IndexRefreshFailure::Transport(error), + other => IndexRefreshFailure::Transport(other.message()), + } + } +} + +/// One availability answer read back from a Core-validated index. The +/// variants mirror Core's own statuses so the marketplace surface can +/// distinguish "the source is broken" from "this plugin cannot run here". +#[derive(Debug, Clone, PartialEq)] +pub enum Availability { + Available { + version: String, + download_url: String, + sha256: String, + }, + /// Core reported the plugin as unusable on this host (`apiUnsupported` + /// or `platformUnsupported`). + Unsupported { code: String, message: String }, + /// The ID is not listed by this index. + NotFound, +} + +/// Reads one plugin's availability out of a Core-validated index document. +pub fn availability_of(validated_index: &Value, plugin_id: &str) -> Availability { + let Some(plugins) = validated_index["plugins"].as_array() else { + return Availability::NotFound; + }; + let Some(plugin) = plugins + .iter() + .find(|plugin| plugin["id"].as_str() == Some(plugin_id)) + else { + return Availability::NotFound; + }; + let availability = &plugin["availability"]; + match availability["status"].as_str() { + Some("available") => Availability::Available { + version: availability["version"] + .as_str() + .unwrap_or_default() + .to_string(), + download_url: availability["downloadURL"] + .as_str() + .unwrap_or_default() + .to_string(), + sha256: availability["sha256"] + .as_str() + .unwrap_or_default() + .to_string(), + }, + Some(status) => Availability::Unsupported { + code: availability["code"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| status.to_string()), + message: availability["message"] + .as_str() + .unwrap_or_default() + .to_string(), + }, + _ => Availability::NotFound, + } +} + +/// Per-source index cache over the caller's runtime state root. All writes +/// land in the caller-provided writable root (`app_local_data_dir` state, or +/// the existing plugin-temp in tests) — never in installation or distribution +/// resources. Same-source publications are serialized per key, and a refresh +/// with a newer generation always wins the cache. +pub struct MarketplaceIndexStore { + root: PathBuf, + /// Highest started generation per `source_id.platform` key. + generations: Mutex>, +} + +/// Versioned raw input, never a cached Core availability answer. Revalidation +/// on every read uses the running Core and the caller's current host context. +/// Legacy normalized caches lack this envelope and are treated as cache misses. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CachedIndex { + cache_format: u32, + source_id: String, + index_url: String, + raw_index: Value, +} + +impl MarketplaceIndexStore { + pub fn new(root: PathBuf) -> Self { + Self { + root, + generations: Mutex::new(HashMap::new()), + } + } + + /// Reads the last-known-good raw index offline and asks the current Core + /// for availability with the current source kind, platform and capabilities. + pub fn cached_index(&self, request: &IndexRefreshRequest) -> Result, String> { + let path = self.cache_path(&request.source_id, &request.platform)?; + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + let envelope: Value = serde_json::from_str(&text) + .map_err(|error| format!("the cached index is unreadable: {error}"))?; + if envelope["cacheFormat"] != 1 { + return Ok(None); + } + let cached: CachedIndex = serde_json::from_value(envelope) + .map_err(|error| format!("the cached index is unreadable: {error}"))?; + if cached.source_id != request.source_id || cached.index_url != request.index_url { + return Ok(None); + } + validate_index(request, &cached.raw_index) + .map(Some) + .map_err(|error| error.message()) + } + + /// Fetches, validates, and (when still the newest request for the key) + /// persists one index. The fetch is bounded by the request deadline, the + /// JSON budget, the redirect limit, the HTTPS policy, and the cancel + /// token; a cancelled or failed refresh leaves the cache untouched. + pub async fn refresh( + &self, + transport: &dyn HttpTransport, + request: &IndexRefreshRequest, + policy: FetchPolicy, + cancel: &CancelToken, + ) -> Result { + self.refresh_with_validator(transport, request, policy, cancel, validate_index) + .await + } + + async fn refresh_with_validator( + &self, + transport: &dyn HttpTransport, + request: &IndexRefreshRequest, + policy: FetchPolicy, + cancel: &CancelToken, + validate: impl FnOnce(&IndexRefreshRequest, &Value) -> Result, + ) -> Result { + let key = + cache_key(&request.source_id, &request.platform).map_err(IndexRefreshFailure::Core)?; + let generation = { + let mut generations = self.generations.lock().map_err(|_| { + IndexRefreshFailure::CacheWrite("the generation map is poisoned".into()) + })?; + let generation = generations.entry(key.clone()).or_insert(0); + *generation += 1; + *generation + }; + if cancel.is_cancelled() { + return Err(IndexRefreshFailure::Cancelled); + } + let deadline = Instant::now() + request.deadline; + let fetch = fetch_controlled( + transport, + &request.index_url, + FetchLimits { + max_bytes: MAX_INDEX_JSON_BYTES, + max_redirects: MAX_REDIRECT_HOPS, + }, + policy, + cancel, + deadline, + ) + .await?; + if cancel.is_cancelled() { + return Err(IndexRefreshFailure::Cancelled); + } + let index_value: Value = serde_json::from_slice(&fetch.body) + .map_err(|error| IndexRefreshFailure::InvalidJson(error.to_string()))?; + let validated = validate(request, &index_value)?; + cancel.check(deadline)?; + // Only the newest started refresh for this key may persist: an older + // request finishing late returns its answer to its caller without + // overwriting the newer cached one. + let persisted = { + let mut generations = self.generations.lock().map_err(|_| { + IndexRefreshFailure::CacheWrite("the generation map is poisoned".into()) + })?; + let newest = generations.entry(key.clone()).or_insert(0); + cancel.check(deadline)?; + let is_newest = generation == *newest; + if is_newest { + self.persist( + &key, + &CachedIndex { + cache_format: 1, + source_id: request.source_id.clone(), + index_url: request.index_url.clone(), + raw_index: index_value, + }, + cancel, + deadline, + )?; + } + is_newest + }; + Ok(RefreshedIndex { + source_id: request.source_id.clone(), + platform: request.platform.clone(), + index: validated, + final_url: fetch.final_url, + persisted, + }) + } + + fn persist( + &self, + key: &str, + cached: &CachedIndex, + cancel: &CancelToken, + deadline: Instant, + ) -> Result<(), IndexRefreshFailure> { + self.persist_with_rename(key, cached, cancel, deadline, |source, target| { + std::fs::rename(source, target) + }) + } + + fn persist_with_rename( + &self, + key: &str, + cached: &CachedIndex, + cancel: &CancelToken, + deadline: Instant, + rename: impl FnOnce(&Path, &Path) -> std::io::Result<()>, + ) -> Result<(), IndexRefreshFailure> { + cancel.check(deadline)?; + std::fs::create_dir_all(&self.root) + .map_err(|error| IndexRefreshFailure::CacheWrite(error.to_string()))?; + let final_path = self.root.join(format!("{key}.json")); + let temp_path = self.root.join(format!( + "{key}.{}.tmp", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() + )); + let bytes = serde_json::to_vec(cached) + .map_err(|error| IndexRefreshFailure::CacheWrite(error.to_string()))?; + let pending = PendingFile::write(temp_path, &bytes) + .map_err(|error| IndexRefreshFailure::CacheWrite(error.to_string()))?; + // Generation ownership is still held by refresh. Cancellation owns a + // separate, short commit lock, so it can win during Core/JSON or IO work. + cancel + .commit(deadline, || pending.publish_with(&final_path, rename))? + .map_err(|error| IndexRefreshFailure::CacheWrite(error.to_string())) + } + + fn cache_path(&self, source_id: &str, platform: &str) -> Result { + let key = cache_key(source_id, platform)?; + Ok(self.root.join(format!("{key}.json"))) + } +} + +fn validate_index( + request: &IndexRefreshRequest, + raw: &Value, +) -> Result { + let outcome = super::scan::core_execute( + "plugins.marketplace.validateIndex", + serde_json::json!({ + "index": raw, + "sourceKind": request.source_kind, + "platform": request.platform, + "hostCapabilities": request.host_capabilities, + }), + ) + .map_err(IndexRefreshFailure::Core)?; + match outcome["status"].as_str() { + Some("ok") if outcome["index"].is_object() => Ok(outcome["index"].clone()), + Some("rejected") => Err(IndexRefreshFailure::Rejected { + code: outcome["error"]["code"] + .as_str() + .unwrap_or("plugin.index.invalid") + .into(), + message: outcome["error"]["message"] + .as_str() + .unwrap_or("the index was rejected") + .into(), + }), + _ => Err(IndexRefreshFailure::Core( + "validateIndex returned no valid outcome".into(), + )), + } +} + +/// The cache key of one source and platform. Source IDs come from the host's +/// source management and must already be slug-shaped; the check keeps a bad +/// ID from escaping the cache root as a path. +fn cache_key(source_id: &str, platform: &str) -> Result { + let key = format!("{source_id}.{platform}"); + let valid = !key.is_empty() + && key.len() <= 128 + && key.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || matches!(character, '.' | '-') + }) + && !key.split('.').any(|part| part == ".."); + if valid { + Ok(key) + } else { + Err(format!( + "the source id {source_id:?} is not a usable cache key" + )) + } +} + +/// Deadline used by callers that just want "bounded"; the marketplace wiring +/// decides the real values. Exported so tests and callers share one shape. +pub fn refresh_deadline(deadline: Duration) -> Instant { + Instant::now() + deadline +} + +/// Path of one cached index document, exposed for diagnostics and cleanup by +/// the source management layer. +pub fn cached_index_path(root: &Path, source_id: &str, platform: &str) -> Result { + Ok(root.join(format!("{}.json", cache_key(source_id, platform)?))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin_host::marketplace_download::tests::{TestHttpServer, TestResponse, TestRoot}; + use sha2::{Digest, Sha256}; + use std::future::Future; + use std::pin::Pin; + use std::sync::Arc; + + const MARKETPLACE_FIXTURES_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures/marketplace" + ); + + fn read_marketplace_fixture(name: &str) -> Value { + serde_json::from_str( + &std::fs::read_to_string(format!("{MARKETPLACE_FIXTURES_DIR}/{name}")) + .expect("marketplace fixture readable"), + ) + .expect("marketplace fixture is JSON") + } + + struct BytesTransport(Vec); + + impl HttpTransport for BytesTransport { + fn get<'a>( + &'a self, + _url: &'a str, + _max_bytes: u64, + _deadline: Instant, + _cancel: &'a CancelToken, + ) -> Pin< + Box< + dyn Future< + Output = Result< + super::super::marketplace_download::HttpResponse, + FetchFailure, + >, + > + Send + + 'a, + >, + > { + Box::pin(async { + Ok(super::super::marketplace_download::HttpResponse { + status: 200, + headers: HashMap::new(), + body: self.0.clone(), + }) + }) + } + } + + fn cached_document(request: &IndexRefreshRequest) -> CachedIndex { + CachedIndex { + cache_format: 1, + source_id: request.source_id.clone(), + index_url: request.index_url.clone(), + raw_index: read_marketplace_fixture("index.json"), + } + } + + fn assert_only_cache(root: &Path, request: &IndexRefreshRequest) { + let names: Vec<_> = std::fs::read_dir(root) + .expect("cache root") + .map(|entry| entry.expect("entry").file_name()) + .collect(); + assert_eq!( + names, + vec![std::ffi::OsString::from(format!( + "{}.json", + cache_key(&request.source_id, &request.platform).expect("key") + ))] + ); + } + + #[tokio::test] + async fn offline_cache_revalidates_capability_upgrades_downgrades_and_source_kind() { + let root = TestRoot::new("capabilities"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let mut request = refresh_request_with_capabilities( + "capabilities", + "https://example.com/index.json".into(), + serde_json::json!({}), + ); + let transport = BytesTransport( + serde_json::to_vec(&read_marketplace_fixture("index.json")).expect("raw"), + ); + let first = store + .refresh( + &transport, + &request, + FetchPolicy::https_only(), + &CancelToken::new(), + ) + .await + .expect("initial refresh"); + assert!(matches!( + availability_of(&first.index, "io.example.elixir-tools"), + Availability::Unsupported { .. } + )); + let path = store + .cache_path(&request.source_id, &request.platform) + .expect("path"); + let saved = std::fs::read(&path).expect("raw cache"); + let envelope: Value = serde_json::from_slice(&saved).expect("envelope"); + assert!( + envelope["rawIndex"]["plugins"][0]["versions"].is_array(), + "persist original version metadata, never normalized availability" + ); + // A new store stands for the next host launch. No transport is available + // on these reads; availability must still follow the current host/Core. + let restarted = MarketplaceIndexStore::new(root.0.clone()); + for (capabilities, version) in [ + ( + serde_json::json!({"languages":[1], "languageServers":[1], "serverProcess":[1]}), + "1.0.0", + ), + ( + serde_json::json!({"languages":[1], "languageServers":[1], "serverProcess":[1], "buildSystems":[1], "buildProviders":[1]}), + "2.0.0", + ), + ] { + request.host_capabilities = capabilities; + let current = restarted + .cached_index(&request) + .expect("offline read") + .expect("cache hit"); + assert!( + matches!(availability_of(¤t, "io.example.elixir-tools"), + Availability::Available { version: selected, .. } if selected == version) + ); + } + request.host_capabilities = serde_json::json!({}); + let downgraded = restarted + .cached_index(&request) + .expect("downgrade") + .expect("cache"); + assert!(matches!( + availability_of(&downgraded, "io.example.elixir-tools"), + Availability::Unsupported { .. } + )); + request.source_kind = SourceKind::Official; + let official = restarted + .cached_index(&request) + .expect("official") + .expect("cache"); + assert!(!matches!( + availability_of(&official, "dev.lithe.fixture.language-packaged-server"), + Availability::NotFound + )); + request.source_kind = SourceKind::ThirdParty; + let third_party = restarted + .cached_index(&request) + .expect("third party") + .expect("cache"); + assert_eq!( + availability_of(&third_party, "dev.lithe.fixture.language-packaged-server"), + Availability::NotFound + ); + assert_eq!(std::fs::read(path).expect("cache unchanged"), saved); + } + + #[test] + fn legacy_normalized_and_unknown_cache_formats_are_safe_misses() { + let root = TestRoot::new("legacy-cache"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let request = refresh_request("legacy", "https://example.com/index.json".into()); + let path = store + .cache_path(&request.source_id, &request.platform) + .expect("path"); + let raw = read_marketplace_fixture("index.json"); + for document in [ + validate_index(&request, &raw).expect("normalized legacy"), + serde_json::json!({"cacheFormat": 99, "rawIndex": raw}), + ] { + let bytes = serde_json::to_vec(&document).expect("legacy bytes"); + std::fs::write(&path, &bytes).expect("seed legacy"); + assert!(store.cached_index(&request).expect("safe miss").is_none()); + assert_eq!(std::fs::read(&path).expect("preserved"), bytes); + } + let mut changed_origin = cached_document(&request); + changed_origin.index_url = "https://other.example.com/index.json".into(); + std::fs::write( + &path, + serde_json::to_vec(&changed_origin).expect("origin bytes"), + ) + .expect("seed"); + assert!(store + .cached_index(&request) + .expect("changed source URL") + .is_none()); + } + + #[test] + fn rename_failure_removes_the_staged_cache_and_preserves_last_known_good_bytes() { + let root = TestRoot::new("rename-failure"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let request = refresh_request("retain", "https://example.com/index.json".into()); + let cached = cached_document(&request); + store + .persist( + "retain.windows-x64", + &cached, + &CancelToken::new(), + refresh_deadline(Duration::from_secs(2)), + ) + .expect("seed cache"); + let path = store + .cache_path(&request.source_id, &request.platform) + .expect("path"); + let saved = std::fs::read(&path).expect("old cache bytes"); + let mut changed = cached; + changed.raw_index["plugins"][0]["name"] = serde_json::json!("must not publish"); + let error = store + .persist_with_rename( + "retain.windows-x64", + &changed, + &CancelToken::new(), + refresh_deadline(Duration::from_secs(2)), + |source, target| { + assert!( + source.is_file(), + "write and flush completed before injected rename failure" + ); + assert_eq!(std::fs::read(target).expect("prior cache"), saved); + Err(std::io::Error::other("injected rename failure")) + }, + ) + .expect_err("rename fails"); + assert_eq!(error.kind(), "cacheWrite"); + assert_eq!(std::fs::read(path).expect("old cache remains"), saved); + assert_only_cache(&root.0, &request); + } + + #[tokio::test] + async fn cancellation_after_core_validation_cannot_replace_the_cache() { + let root = TestRoot::new("cancel-validation"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let request = refresh_request("retain", "https://example.com/index.json".into()); + let cached = cached_document(&request); + store + .persist( + "retain.windows-x64", + &cached, + &CancelToken::new(), + refresh_deadline(Duration::from_secs(2)), + ) + .expect("seed"); + let path = store + .cache_path(&request.source_id, &request.platform) + .expect("path"); + let saved = std::fs::read(&path).expect("prior bytes"); + let mut raw = read_marketplace_fixture("index.json"); + raw["plugins"][0]["name"] = serde_json::json!("cancelled answer"); + let transport = BytesTransport(serde_json::to_vec(&raw).expect("raw bytes")); + let cancel = CancelToken::new(); + let result = store + .refresh_with_validator( + &transport, + &request, + FetchPolicy::https_only(), + &cancel, + |request, raw| { + let validated = validate_index(request, raw)?; + cancel.cancel(); + Ok(validated) + }, + ) + .await; + assert_eq!( + result.expect_err("cancelled before publication"), + IndexRefreshFailure::Cancelled + ); + assert_eq!(std::fs::read(path).expect("cache remains"), saved); + assert_only_cache(&root.0, &request); + } + + fn refresh_request(source_id: &str, url: String) -> IndexRefreshRequest { + refresh_request_with_capabilities(source_id, url, super::super::host_capabilities()) + } + + fn refresh_request_with_capabilities( + source_id: &str, + url: String, + host_capabilities: Value, + ) -> IndexRefreshRequest { + IndexRefreshRequest { + source_id: source_id.to_string(), + index_url: url, + source_kind: SourceKind::ThirdParty, + platform: "windows-x64".to_string(), + host_capabilities, + deadline: Duration::from_secs(10), + } + } + + /// The refreshed index must answer exactly like the shared fixture case + /// for windows-x64: the same availability statuses, versions, digests, + /// and the same refused entries in index order. + #[tokio::test] + async fn refresh_matches_the_shared_standard_answer() { + let index = read_marketplace_fixture("index.json"); + let case = read_marketplace_fixture("index-cases.json")["cases"] + .as_array() + .expect("cases") + .iter() + .find(|case| { + case["name"] + == serde_json::json!("a capability Core cannot validate is never offered") + }) + .expect("the capability-gated case") + .clone(); + let server = TestHttpServer::start(vec![( + "/index.json", + vec![TestResponse::ok( + serde_json::to_vec(&index).expect("index bytes"), + )], + )]); + let root = TestRoot::new("golden"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let refreshed = store + .refresh( + &crate::plugin_host::marketplace_download::ReqwestTransport::new() + .expect("transport"), + &refresh_request_with_capabilities( + "golden", + format!("{}/index.json", server.base_url), + case["hostCapabilities"].clone(), + ), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + ) + .await + .expect("the refresh succeeds"); + assert!(refreshed.persisted); + + // Availability answers come from Core, entry by entry. + let expected_plugins = case["expected"]["plugins"] + .as_array() + .expect("expected plugins"); + assert_eq!( + refreshed.index["plugins"] + .as_array() + .expect("plugins") + .len(), + expected_plugins.len() + ); + for expected in expected_plugins { + let id = expected["id"].as_str().expect("id"); + let expected_status = expected["availability"]["status"].as_str().expect("status"); + match availability_of(&refreshed.index, id) { + Availability::Available { + version, + download_url, + sha256, + } => { + assert_eq!(expected_status, "available", "{id}"); + assert_eq!(version, expected["availability"]["version"]); + assert_eq!( + sha256, + expected["availability"]["sha256"].as_str().expect("sha") + ); + assert!( + !download_url.is_empty(), + "an available entry carries the download URL" + ); + } + Availability::Unsupported { code, message } => { + assert_eq!(expected_status, "unsupported", "{id}"); + assert_eq!( + code, + expected["availability"]["code"].as_str().expect("code"), + "{id}" + ); + assert!(!message.is_empty()); + } + Availability::NotFound => panic!("the listed plugin {id} went missing"), + } + } + // The refused entries keep Core's stable codes. + let expected_rejected = case["expected"]["rejected"] + .as_array() + .expect("expected rejected"); + let rejected = refreshed.index["rejected"] + .as_array() + .expect("rejected") + .clone(); + for expected in expected_rejected { + let id = expected["id"].as_str().expect("id"); + let code = expected["code"].as_str().expect("code"); + assert!( + rejected.iter().any(|entry| entry["id"] == expected["id"] + && entry["error"]["code"] == serde_json::json!(code)), + "the refused entry {id} must report {code}: {rejected:?}" + ); + } + } + + /// A failed refresh — invalid JSON, a Core-rejected index, an HTTP + /// failure, a budget overrun — never overwrites the last-known-good + /// cache. + #[tokio::test] + async fn failed_refreshes_keep_the_last_known_good_index() { + let index = read_marketplace_fixture("index.json"); + let index_bytes = serde_json::to_vec(&index).expect("index bytes"); + let broken_cases: Vec<&str> = vec!["not-json", "no-schema", "missing", "budget"]; + let server = TestHttpServer::start(vec![ + ("/good", vec![TestResponse::ok(index_bytes)]), + ("/not-json", vec![TestResponse::ok(b"{ not json".to_vec())]), + ( + "/no-schema", + vec![TestResponse::ok(br#"{"plugins": []}"#.to_vec())], + ), + ("/missing", vec![TestResponse::with_status(404)]), + ( + "/budget", + // The index budget is 8 MiB; a 9 MiB body exceeds it and the + // transport aborts mid-body at the budget. + vec![TestResponse::ok(vec![b'x'; 9 * 1024 * 1024])], + ), + ]); + let root = TestRoot::new("retain"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let transport = + crate::plugin_host::marketplace_download::ReqwestTransport::new().expect("transport"); + + let good = store + .refresh( + &transport, + &refresh_request("retain", format!("{}/good", server.base_url)), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + ) + .await + .expect("the good refresh succeeds"); + assert!(good.persisted); + + for label in &broken_cases { + let error = store + .refresh( + &transport, + &refresh_request("retain", format!("{}/{label}", server.base_url)), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + ) + .await + .expect_err("the broken refresh must fail"); + match *label { + "not-json" => assert_eq!(error.kind(), "invalidJson", "{error:?}"), + "no-schema" => { + assert_eq!(error.kind(), "indexRejected", "{error:?}"); + assert!(error.message().contains("schemaVersion"), "{error:?}"); + } + "missing" => assert_eq!(error.kind(), "httpError", "{error:?}"), + "budget" => assert_eq!(error.kind(), "budgetExceeded", "{error:?}"), + other => panic!("unexpected broken case {other}: {error:?}"), + } + // The failure kinds are distinguishable per case, and the cache + // still holds the good answer. + let cached = store + .cached_index(&refresh_request( + "retain", + format!("{}/good", server.base_url), + )) + .expect("cache readable") + .expect("last known good kept"); + assert!(cached["plugins"].as_array().expect("plugins").len() >= 3); + } + } + + /// A refresh that started before a newer one but finishes after it never + /// overwrites the newer cache entry; it still answers its own caller. + #[tokio::test] + async fn older_refresh_finishing_late_never_overwrites_the_newer_cache() { + use crate::plugin_host::marketplace_download::HttpTransport; + + let mut index_old = read_marketplace_fixture("index.json"); + let mut index_new = read_marketplace_fixture("index.json"); + for (document, name) in [ + (&mut index_old, "Older Refresh Name"), + (&mut index_new, "Newer Refresh Name"), + ] { + // Core sorts the validated plugins by ID, so the elixir entry is + // the first of the cached document; both refreshes mutate it. + let entry = document["plugins"] + .as_array_mut() + .expect("plugins") + .iter_mut() + .find(|entry| entry["id"] == serde_json::json!("io.example.elixir-tools")) + .expect("elixir entry"); + entry["name"] = serde_json::json!(name); + } + + let server = TestHttpServer::start(vec![ + ( + "/old", + vec![TestResponse::ok( + serde_json::to_vec(&index_old).expect("old bytes"), + )], + ), + ( + "/new", + vec![TestResponse::ok( + serde_json::to_vec(&index_new).expect("new bytes"), + )], + ), + ]); + struct GatedTransport { + inner: crate::plugin_host::marketplace_download::ReqwestTransport, + gate_path: String, + entered: std::sync::Mutex>>, + release: Arc, + } + impl HttpTransport for GatedTransport { + fn get<'a>( + &'a self, + url: &'a str, + max_bytes: u64, + deadline: Instant, + cancel: &'a CancelToken, + ) -> Pin< + Box< + dyn Future< + Output = Result< + crate::plugin_host::marketplace_download::HttpResponse, + FetchFailure, + >, + > + Send + + 'a, + >, + > { + let entered = self + .entered + .lock() + .expect("entered") + .take() + .filter(|_| url.contains(&self.gate_path)); + let release = self.release.clone(); + Box::pin(async move { + if let Some(entered) = entered { + let _ = entered.send(()); + // Bounded: the test releases within its own runtime. + tokio::time::timeout(Duration::from_secs(10), release.notified()) + .await + .map_err(|_| FetchFailure::DeadlineExceeded)?; + } + self.inner.get(url, max_bytes, deadline, cancel).await + }) + } + } + + let root = TestRoot::new("order"); + let store = Arc::new(MarketplaceIndexStore::new(root.0.clone())); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel::<()>(); + let release = Arc::new(tokio::sync::Notify::new()); + let gated = Arc::new(GatedTransport { + inner: crate::plugin_host::marketplace_download::ReqwestTransport::new() + .expect("transport"), + gate_path: "/old".to_string(), + entered: std::sync::Mutex::new(Some(entered_tx)), + release: release.clone(), + }); + let plain = + crate::plugin_host::marketplace_download::ReqwestTransport::new().expect("transport"); + let old_url = format!("{}/old", server.base_url); + let new_url = format!("{}/new", server.base_url); + + // Both futures are owned by this scope. If an assertion unwinds, the + // parked request is dropped rather than left in a detached Tokio task. + let old_request = refresh_request("order", old_url); + let old_cancel = CancelToken::new(); + let older = store.refresh( + gated.as_ref(), + &old_request, + FetchPolicy::loopback_http_for_tests(), + &old_cancel, + ); + tokio::pin!(older); + tokio::select! { + result = &mut older => panic!("older request must be gated: {result:?}"), + entered = tokio::time::timeout(Duration::from_secs(2), entered_rx) => { + entered.expect("older request enters within its bound").expect("gate sender alive"); + } + } + + let newer = store + .refresh( + &plain, + &refresh_request("order", new_url), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + ) + .await + .expect("the newer refresh succeeds"); + assert!(newer.persisted); + + release.notify_one(); + let older = tokio::time::timeout(Duration::from_secs(2), &mut older) + .await + .expect("the older refresh finishes within its bound") + .expect("the older refresh still answers its caller"); + assert!( + !older.persisted, + "the late finishing refresh must not overwrite the newer cache" + ); + let cached = store + .cached_index(&refresh_request( + "order", + format!("{}/new", server.base_url), + )) + .expect("cache readable") + .expect("the newer answer is cached"); + assert_eq!( + cached["plugins"][0]["name"], + serde_json::json!("Newer Refresh Name"), + "the cached index is the newer refresh's document" + ); + } + + #[tokio::test] + async fn cancelled_and_deadline_refreshes_keep_the_cache() { + let index = read_marketplace_fixture("index.json"); + let server = TestHttpServer::start(vec![( + "/index.json", + vec![TestResponse::ok( + serde_json::to_vec(&index).expect("index bytes"), + )], + )]); + let root = TestRoot::new("cancel"); + let store = MarketplaceIndexStore::new(root.0.clone()); + let transport = + crate::plugin_host::marketplace_download::ReqwestTransport::new().expect("transport"); + + let good = store + .refresh( + &transport, + &refresh_request("cancel", format!("{}/index.json", server.base_url)), + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + ) + .await + .expect("the good refresh succeeds"); + assert!(good.persisted); + + let cancel = CancelToken::new(); + cancel.cancel(); + let error = store + .refresh( + &transport, + &refresh_request("cancel", format!("{}/index.json", server.base_url)), + FetchPolicy::loopback_http_for_tests(), + &cancel, + ) + .await + .expect_err("the cancelled refresh must fail"); + assert_eq!(error.kind(), "cancelled"); + + let mut expired = refresh_request("cancel", format!("{}/index.json", server.base_url)); + expired.deadline = Duration::ZERO; + let error = store + .refresh( + &transport, + &expired, + FetchPolicy::loopback_http_for_tests(), + &CancelToken::new(), + ) + .await + .expect_err("the expired refresh must fail"); + assert_eq!(error.kind(), "deadline"); + + let cached = store + .cached_index(&refresh_request( + "cancel", + format!("{}/index.json", server.base_url), + )) + .expect("cache readable") + .expect("kept"); + assert!(cached["plugins"].as_array().expect("plugins").len() >= 3); + } + + /// Unusable cache keys are refused instead of escaping the cache root, + /// and the SHA-256 the index publishes is the one a download binds to. + #[test] + fn cache_keys_are_validated_and_sha256_round_trips() { + let root = TestRoot::new("keys"); + let store = MarketplaceIndexStore::new(root.0.clone()); + assert!(store + .cached_index(&refresh_request( + "../evil", + "https://example.com/index.json".into() + )) + .is_err()); + assert!(store + .cached_index(&refresh_request( + "UPPER", + "https://example.com/index.json".into() + )) + .is_err()); + assert!(store + .cached_index(&refresh_request( + "official", + "https://example.com/index.json".into() + )) + .expect("usable key") + .is_none()); + + let artifact_sha = { + let digest = Sha256::digest(b"future artifact"); + format!("{:x}", digest) + }; + assert_eq!(artifact_sha.len(), 64); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/marketplace_sources.rs b/windows/tauri/src-tauri/src/plugin_host/marketplace_sources.rs new file mode 100644 index 0000000000..ee455c0eac --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/marketplace_sources.rs @@ -0,0 +1,495 @@ +//! Marketplace source list for declarative plugins: the preset sources the +//! host rebuilds from constants, user-added third-party sources, enable +//! state, and their persistence under `app_local_data_dir()`. +//! +//! Semantics mirror the macOS feature model +//! (`macos/Sources/Lithe/Application/Features/PluginMarketplace.swift`) and +//! its adapter (`MacPluginMarketplaceService`): the official source can be +//! disabled but never removed, Lithe Store is a removable preset that stays +//! removed across restarts, source IDs are the same FNV-1a derivation over +//! the index URL, and URL resolution is decided by Core +//! (`plugins.marketplace.resolveSource`) — never reinterpreted here. Index +//! fetching, downloads, and installation are later M2 slices and do not +//! belong to this module. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; + +/// The official source: host constant, rebuildable, undeletable. +pub const OFFICIAL_SOURCE_ID: &str = "lithe-official"; +pub const OFFICIAL_INDEX_URL: &str = + "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json"; +pub const OFFICIAL_REPOSITORY: &str = "https://github.com/Lithe-IDEA/Lithe-IDEA"; +/// Lithe Store, the preset community source. Unlike the official source it +/// is third-party and the user may remove it; removal is remembered so the +/// next launch does not add it back. +pub const COMMUNITY_REPOSITORY: &str = "https://github.com/Lithe-IDEA/Lithe-store"; +pub const COMMUNITY_INDEX_URL: &str = + "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-store/HEAD/lithe-plugin-index.json"; + +const SOURCES_SCHEMA_VERSION: u64 = 1; +const OFFICIAL_SOURCE_NAME: &str = "Lithe"; +const COMMUNITY_SOURCE_NAME: &str = "Lithe Store"; + +/// The largest number of corruption backups one store keeps before giving +/// up; a backup must never overwrite a previous one, so disk-exhausting +/// loops terminate with a diagnosable error instead. +pub(crate) const MAXIMUM_CORRUPTION_BACKUPS: u32 = 64; + +/// Serializes every sources.json read-modify-write across the host's +/// windows: each command loads, mutates, and persists the whole list, so +/// two concurrent transactions would otherwise drop one side's update. +/// The store is one file per user, so a process-wide lock over the +/// transaction is the exact scope needed. +static SOURCES_LOCK: Mutex<()> = Mutex::new(()); + +/// Distinguishes concurrent temp files of the same store (multi-window +/// writes and, in principle, several app instances) so a fixed temp name +/// can never collide mid-rename. +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +pub(crate) fn sources_lock() -> &'static Mutex<()> { + &SOURCES_LOCK +} + +/// One marketplace source. Serialized field names match the macOS +/// `sources.json` shape so the stored bytes stay cross-platform readable. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct MarketplaceSource { + pub id: String, + pub name: String, + /// What the user entered, or the official index URL for a preset. + pub input: String, + #[serde(rename = "indexURL")] + pub index_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository: Option, + #[serde(rename = "isEnabled")] + pub is_enabled: bool, +} + +/// Stable, filesystem-safe ID for a source, derived from its index URL with +/// the exact FNV-1a derivation macOS uses +/// (`PluginMarketplaceSource.id(forIndexURL:)`): offset basis, FNV prime, +/// then `source-` plus lowercase hexadecimal without padding. The same URL +/// must therefore never be addable twice. +pub(crate) fn source_id_for_index_url(index_url: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in index_url.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("source-{hash:x}") +} + +/// Whether two index URLs name the same index. GitHub hosts lowercase their +/// addresses, and a user may type the preset's URL in a different case, so +/// the comparison is Unicode case-insensitive like macOS +/// `caseInsensitiveCompare`. +fn same_index(left: &str, right: &str) -> bool { + left.to_lowercase() == right.to_lowercase() +} + +fn official_source() -> MarketplaceSource { + MarketplaceSource { + id: OFFICIAL_SOURCE_ID.to_string(), + name: OFFICIAL_SOURCE_NAME.to_string(), + input: OFFICIAL_INDEX_URL.to_string(), + index_url: OFFICIAL_INDEX_URL.to_string(), + repository: Some(OFFICIAL_REPOSITORY.to_string()), + is_enabled: true, + } +} + +fn community_source() -> MarketplaceSource { + MarketplaceSource { + id: source_id_for_index_url(COMMUNITY_INDEX_URL), + name: COMMUNITY_SOURCE_NAME.to_string(), + input: COMMUNITY_REPOSITORY.to_string(), + index_url: COMMUNITY_INDEX_URL.to_string(), + repository: Some(COMMUNITY_REPOSITORY.to_string()), + is_enabled: true, + } +} + +/// `sources.json` next to the other plugin-host stores. Writes only ever +/// target `app_local_data_dir()`; installed packages stay read-only. +fn sources_path(app: &AppHandle) -> Result { + Ok(app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-marketplace") + .join("sources.json")) +} + +/// The persisted shape. `removedPresetSourceIDs` records presets the user +/// removed so a restart does not add them back. +#[derive(Deserialize, Serialize)] +struct SourcesFile { + #[serde(rename = "schemaVersion")] + schema_version: u64, + #[serde(default)] + sources: Vec, + #[serde( + default, + rename = "removedPresetSourceIDs", + skip_serializing_if = "Option::is_none" + )] + removed_preset_source_ids: Option>, +} + +/// Rebuilds the preset sources from host constants; only their enable state +/// is the user's. A user-added source typed under a differently cased +/// Lithe Store address stands in for the preset, keeping the ID its plugins +/// already recorded — the same rule as macOS `loadSources`. Takes the host +/// sources lock because a corrupted store is moved aside while reading. +pub(crate) fn load_sources( + path: &Path, +) -> Result<(Vec, Option), String> { + let _guard = sources_lock() + .lock() + .map_err(|error| format!("plugin.marketplace.sources.lock: {error}"))?; + load_sources_unlocked(path) +} + +fn load_sources_unlocked(path: &Path) -> Result<(Vec, Option), String> { + let mut official = official_source(); + let mut community = community_source(); + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok((vec![official, community], None)); + } + Err(error) => return Err(error.to_string()), + }; + let parsed: Result = serde_json::from_str(&text); + let file: SourcesFile = match parsed { + Ok(file) if file.schema_version == SOURCES_SCHEMA_VERSION => file, + Ok(file) => { + let problem = format!( + "plugin.marketplace.sources.corrupt: schemaVersion {} is not supported", + file.schema_version + ); + return Ok((preserve_corrupt_file(path)?, Some(problem))); + } + Err(error) => { + let problem = format!("plugin.marketplace.sources.corrupt: {error}"); + return Ok((preserve_corrupt_file(path)?, Some(problem))); + } + }; + official.is_enabled = file + .sources + .iter() + .find(|source| source.id == official.id) + .map(|source| source.is_enabled) + .unwrap_or(true); + // Lithe Store added by hand before it became a preset already has the + // preset ID, so only its enabled state carries over (macOS rule). + community.is_enabled = file + .sources + .iter() + .find(|source| source.id == community.id) + .map(|source| source.is_enabled) + .unwrap_or(true); + let preset_ids = [OFFICIAL_SOURCE_ID.to_string(), community.id.clone()]; + let third_party: Vec = file + .sources + .iter() + .filter(|source| !preset_ids.contains(&source.id)) + .cloned() + .collect(); + if file + .removed_preset_source_ids + .as_ref() + .is_some_and(|removed| removed.contains(&community.id)) + { + return Ok((prepend_official(official, third_party), None)); + } + if third_party + .iter() + .any(|source| same_index(&source.index_url, &community.index_url)) + { + return Ok((prepend_official(official, third_party), None)); + } + Ok(( + prepend_official( + official, + std::iter::once(community).chain(third_party).collect(), + ), + None, + )) +} + +fn prepend_official( + official: MarketplaceSource, + mut sources: Vec, +) -> Vec { + // The rebuild never lets a stored preset entry's name or URL stand in; + // the merged enable state lives in `official` already. + sources.insert(0, official); + sources +} + +/// A corrupted store must never be silently reset and never destroy +/// earlier backups: the original bytes move to the first free +/// `sources.json.corrupt-` (n = 1..64), keeping every historical copy, +/// and the list rebuilds from presets. A failed backup leaves the +/// unreadable file in place untouched. +fn preserve_corrupt_file(path: &Path) -> Result, String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + for attempt in 1..=MAXIMUM_CORRUPTION_BACKUPS { + let backup = path.with_extension(format!("json.corrupt-{attempt}")); + if backup.exists() { + continue; + } + return match std::fs::rename(path, &backup) { + Ok(()) => Ok(vec![official_source(), community_source()]), + Err(error) => Err(format!( + "plugin.marketplace.sources.corrupt: preserving the unreadable file failed: {error}" + )), + }; + } + Err(format!( + "plugin.marketplace.sources.corrupt: all {MAXIMUM_CORRUPTION_BACKUPS} backup slots exist; move them aside and retry" + )) +} + +/// Writes the store atomically via a unique temp file + rename, recomputing +/// the removed-preset tombstones from the list: a preset missing here was +/// removed by the user, and one present again clears its tombstone. +pub(crate) fn save_sources(path: &Path, sources: &[MarketplaceSource]) -> Result<(), String> { + let _guard = sources_lock() + .lock() + .map_err(|error| format!("plugin.marketplace.sources.lock: {error}"))?; + save_sources_unlocked(path, sources) +} + +fn save_sources_unlocked(path: &Path, sources: &[MarketplaceSource]) -> Result<(), String> { + let removed: Vec = [community_source().id] + .into_iter() + .filter(|id| !sources.iter().any(|source| &source.id == id)) + .collect(); + let file = SourcesFile { + schema_version: SOURCES_SCHEMA_VERSION, + sources: sources.to_vec(), + // macOS omits the key when nothing was removed; an absent key and an + // empty list mean the same thing to both loaders. + removed_preset_source_ids: if removed.is_empty() { + None + } else { + Some(removed) + }, + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temp = path.with_extension(format!("json.tmp-{}-{sequence}", std::process::id())); + // A failed write or rename must not leave this attempt's temp file + // behind; the previous store stays untouched either way. + let result = std::fs::write( + &temp, + serde_json::to_string_pretty(&file).map_err(|error| error.to_string())?, + ) + .and_then(|()| std::fs::rename(&temp, path)) + .map_err(|error| error.to_string()); + if result.is_err() { + let _ = std::fs::remove_file(&temp); + } + result +} + +/// Adds a source for `input`. Core decides what the input names; the host +/// derives the stable ID, refuses duplicates (by ID or by case-insensitive +/// index URL), and keeps `isEnabled` of everything already listed. A +/// re-added Lithe Store takes its preset ID back in any casing, which also +/// clears its removal tombstone on the next save. The whole +/// load-resolve-mutate-persist sequence holds the host sources lock so +/// concurrent windows cannot drop each other's update. +pub(crate) fn add_source(path: &Path, input: &str) -> Result, String> { + let _guard = sources_lock() + .lock() + .map_err(|error| format!("plugin.marketplace.sources.lock: {error}"))?; + let input = input.trim(); + if input.is_empty() { + return Err("plugin.marketplace.sources.emptyInput".to_string()); + } + let resolution = resolve_via_core(input)?; + let index_url = resolution + .get("indexURL") + .and_then(Value::as_str) + .ok_or_else(|| "plugin.marketplace.sources.invalidResolution".to_string())? + .to_string(); + let repository = resolution + .get("repository") + .and_then(Value::as_str) + .map(str::to_string); + let name = resolution + .get("displayName") + .and_then(Value::as_str) + .unwrap_or(index_url.as_str()) + .to_string(); + let community = community_source(); + let id = if same_index(&index_url, &community.index_url) { + community.id + } else { + source_id_for_index_url(&index_url) + }; + let (mut sources, _) = load_sources_unlocked(path)?; + if sources + .iter() + .any(|source| source.id == id || same_index(&source.index_url, &index_url)) + { + return Err(format!("plugin.marketplace.sourceAlreadyAdded: {name}")); + } + sources.push(MarketplaceSource { + id, + name, + input: input.to_string(), + index_url, + repository, + is_enabled: true, + }); + save_sources_unlocked(path, &sources)?; + Ok(sources) +} + +/// Toggles one source's enable state under the host sources lock. An +/// unknown ID is a no-op, matching the macOS feature model. +pub(crate) fn set_enabled( + path: &Path, + source_id: &str, + enabled: bool, +) -> Result, String> { + let _guard = sources_lock() + .lock() + .map_err(|error| format!("plugin.marketplace.sources.lock: {error}"))?; + let (mut sources, _) = load_sources_unlocked(path)?; + if let Some(position) = sources.iter().position(|source| source.id == source_id) { + sources[position].is_enabled = enabled; + save_sources_unlocked(path, &sources)?; + } + Ok(sources) +} + +/// Removes a third-party source under the host sources lock. The official +/// source is refused; removing Lithe Store persists its tombstone so +/// restarts do not revive it. +pub(crate) fn remove_source( + path: &Path, + source_id: &str, +) -> Result, String> { + let _guard = sources_lock() + .lock() + .map_err(|error| format!("plugin.marketplace.sources.lock: {error}"))?; + let (sources, _) = load_sources_unlocked(path)?; + let Some(source) = sources.iter().find(|source| source.id == source_id) else { + return Err("plugin.marketplace.sourceNotFound".to_string()); + }; + if source.id == OFFICIAL_SOURCE_ID { + return Err("plugin.marketplace.officialSourceCannotBeRemoved".to_string()); + } + let remaining: Vec = sources + .into_iter() + .filter(|source| source.id != source_id) + .collect(); + save_sources_unlocked(path, &remaining)?; + Ok(remaining) +} + +/// One source as the frontend sees it: the stored fields plus the kind and +/// the actions the UI may offer. +pub(crate) fn source_view(source: &MarketplaceSource) -> Value { + let official = source.id == OFFICIAL_SOURCE_ID; + let mut value = serde_json::to_value(source).expect("source serializes"); + value["kind"] = json!(if official { "official" } else { "thirdParty" }); + value["isOfficial"] = json!(official); + value["deletable"] = json!(!official); + value +} + +fn source_views(sources: &[MarketplaceSource]) -> Vec { + sources.iter().map(source_view).collect() +} + +/// Calls Core for the one decision this module delegates: what the user's +/// input names. The error keeps Core's stable code so the UI can show +/// `plugin.source.invalid` instead of prose. Marketplace commands return +/// rejections in-band (`data.status: "rejected"` with the stable code); +/// malformed requests fail on the envelope itself. +fn resolve_via_core(input: &str) -> Result { + let request = json!({ + "command": "plugins.marketplace.resolveSource", + "payload": { "url": input }, + }); + let response_text = lithe_core::execute_json(&request.to_string()); + let response: Value = serde_json::from_str(&response_text).map_err(|error| { + format!("plugins.marketplace.resolveSource returned an unreadable response: {error}") + })?; + if response["ok"] == json!(true) { + let data = response["data"].clone(); + if data["status"] == json!("ok") { + return Ok(data["source"].clone()); + } + Err(rejection_message(&data["error"])) + } else { + Err(rejection_message(&response["error"])) + } +} + +fn rejection_message(error: &Value) -> String { + let code = error["code"].as_str().unwrap_or("unknown"); + let message = error["message"].as_str().unwrap_or("unknown error"); + format!("{code}: {message}") +} + +/// Tauri command: current sources plus the corruption diagnosis, if the +/// store had to be rebuilt. +#[tauri::command] +pub fn declarative_marketplace_sources_state(app: AppHandle) -> Result { + let path = sources_path(&app)?; + let (sources, problem) = load_sources(&path)?; + Ok(json!({ "sources": source_views(&sources), "problem": problem })) +} + +/// Tauri command: resolves and adds a third-party source, returning the +/// updated list. +#[tauri::command] +pub fn declarative_marketplace_sources_add( + app: AppHandle, + url: String, +) -> Result, String> { + let path = sources_path(&app)?; + let sources = add_source(&path, &url)?; + Ok(source_views(&sources)) +} + +/// Tauri command: enables or disables one source, returning the updated list. +#[tauri::command] +pub fn declarative_marketplace_sources_set_enabled( + app: AppHandle, + source_id: String, + enabled: bool, +) -> Result, String> { + let path = sources_path(&app)?; + let sources = set_enabled(&path, &source_id, enabled)?; + Ok(source_views(&sources)) +} + +/// Tauri command: removes a deletable source, returning the updated list. +#[tauri::command] +pub fn declarative_marketplace_sources_remove( + app: AppHandle, + source_id: String, +) -> Result, String> { + let path = sources_path(&app)?; + let sources = remove_source(&path, &source_id)?; + Ok(source_views(&sources)) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/marketplace_sources_tests.rs b/windows/tauri/src-tauri/src/plugin_host/marketplace_sources_tests.rs new file mode 100644 index 0000000000..d03bd9c853 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/marketplace_sources_tests.rs @@ -0,0 +1,585 @@ +//! M2 source-management tests exercise the internal functions directly (the +//! Tauri commands are thin AppHandle wrappers): preset rebuild from host +//! constants, the FNV source ID standard answers shared with macOS, +//! duplicate and official-source refusal, the Lithe Store tombstone, and the +//! corrupted-store handling. Everything runs offline; URL resolution goes +//! through the real Core `plugins.marketplace.resolveSource` command, whose +//! golden answers live in the shared `source-urls.json` fixture. + +mod marketplace_sources_tests { + use crate::plugin_host::marketplace_sources::{ + add_source, load_sources, remove_source, save_sources, set_enabled, + source_id_for_index_url, source_view, COMMUNITY_INDEX_URL, MAXIMUM_CORRUPTION_BACKUPS, + OFFICIAL_INDEX_URL, OFFICIAL_SOURCE_ID, + }; + use serde_json::{json, Value}; + use std::path::{Path, PathBuf}; + + /// Fixed standard answers, matching the macOS FNV-1a derivation; the + /// expectations are literals, never recomputed by the code under test. + const COMMUNITY_SOURCE_ID: &str = "source-39170ddcae1870bf"; + const EXAMPLE_INDEX_URL: &str = + "https://raw.githubusercontent.com/example/lithe-plugins/HEAD/lithe-plugin-index.json"; + const EXAMPLE_SOURCE_ID: &str = "source-94ba41e9fc33730a"; + + fn unique_temp_root(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-marketplace-sources-{label}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp root"); + root + } + + fn sources_path(root: &Path) -> PathBuf { + root.join("plugin-marketplace").join("sources.json") + } + + fn cleanup(root: &Path) { + let _ = std::fs::remove_dir_all(root); + } + + fn ids(sources: &[Value]) -> Vec { + sources + .iter() + .filter_map(|source| source["id"].as_str().map(str::to_string)) + .collect() + } + + #[test] + fn standard_source_ids_match_the_fixed_answers() { + assert_eq!( + source_id_for_index_url(COMMUNITY_INDEX_URL), + COMMUNITY_SOURCE_ID + ); + assert_eq!( + source_id_for_index_url(EXAMPLE_INDEX_URL), + EXAMPLE_SOURCE_ID + ); + // The official source is a host constant, not a hash. + assert_eq!(OFFICIAL_SOURCE_ID, "lithe-official"); + } + + #[test] + fn preset_sources_rebuild_from_host_constants() { + let root = unique_temp_root("presets"); + let (sources, problem) = load_sources(&sources_path(&root)).expect("defaults load"); + assert_eq!( + ids(&sources.iter().map(source_view).collect::>()), + vec![ + OFFICIAL_SOURCE_ID.to_string(), + COMMUNITY_SOURCE_ID.to_string(), + ] + ); + assert_eq!(sources[0].name, "Lithe"); + assert_eq!(sources[0].index_url, OFFICIAL_INDEX_URL); + assert_eq!(sources[1].name, "Lithe Store"); + assert_eq!(sources[1].id, COMMUNITY_SOURCE_ID); + assert!(sources[1] + .index_url + .starts_with("https://raw.githubusercontent.com/Lithe-IDEA/Lithe-store/")); + assert!(problem.is_none()); + let views: Vec = sources.iter().map(source_view).collect(); + assert_eq!(views[0]["deletable"], json!(false)); + assert_eq!(views[0]["kind"], json!("official")); + assert_eq!(views[1]["deletable"], json!(true)); + assert_eq!(views[1]["kind"], json!("thirdParty")); + cleanup(&root); + } + + #[test] + fn add_source_resolves_through_core_with_the_standard_answers() { + let root = unique_temp_root("add"); + let path = sources_path(&root); + let sources = add_source(&path, "https://github.com/example/lithe-plugins") + .expect("github repository resolves"); + assert_eq!(sources.len(), 3); + let added = sources.last().expect("added source"); + assert_eq!(added.id, EXAMPLE_SOURCE_ID); + assert_eq!(added.index_url, EXAMPLE_INDEX_URL); + assert_eq!(added.name, "example/lithe-plugins"); + assert_eq!( + added.repository.as_deref(), + Some("https://github.com/example/lithe-plugins") + ); + assert!(added.is_enabled); + // The added source survives a restart. + let (reloaded, problem) = load_sources(&path).expect("reload"); + assert!(problem.is_none()); + assert!(reloaded.iter().any(|source| source.id == EXAMPLE_SOURCE_ID)); + cleanup(&root); + } + + #[test] + fn add_source_keeps_core_rejection_codes() { + let root = unique_temp_root("invalid"); + let path = sources_path(&root); + let plain_http = add_source(&path, "http://plugins.example.com/index.json") + .expect_err("plain http is refused"); + assert!(plain_http.contains("plugin.source.invalid"), "{plain_http}"); + let empty = add_source(&path, " ").expect_err("empty input is refused"); + assert!( + empty.contains("plugin.marketplace.sources.emptyInput"), + "{empty}" + ); + let (sources, _) = load_sources(&path).expect("nothing was added"); + assert_eq!(sources.len(), 2); + cleanup(&root); + } + + #[test] + fn duplicate_add_is_refused_and_state_is_kept() { + let root = unique_temp_root("duplicate"); + let path = sources_path(&root); + add_source(&path, "https://github.com/example/lithe-plugins").expect("first add"); + set_enabled(&path, EXAMPLE_SOURCE_ID, false).expect("disable"); + // Same index under a different host case, `.git` suffix, surrounding + // whitespace, and a trailing slash: Core normalizes all of it to the + // same index URL, so the host must refuse the duplicate. + let duplicate = add_source(&path, " https://GitHub.com/EXAMPLE/lithe-plugins.git/ ") + .expect_err("duplicate source is refused"); + assert!( + duplicate.contains("plugin.marketplace.sourceAlreadyAdded"), + "{duplicate}" + ); + let (sources, _) = load_sources(&path).expect("reload"); + assert_eq!( + sources.len(), + 3, + "only official, community, and the original entry" + ); + let example = sources + .iter() + .find(|s| s.id == EXAMPLE_SOURCE_ID) + .expect("kept"); + assert!(!example.is_enabled, "the stored enabled state survives"); + cleanup(&root); + } + + #[test] + fn official_source_cannot_be_removed() { + let root = unique_temp_root("official"); + let path = sources_path(&root); + let refused = + remove_source(&path, OFFICIAL_SOURCE_ID).expect_err("official is undeletable"); + assert!( + refused.contains("plugin.marketplace.officialSourceCannotBeRemoved"), + "{refused}" + ); + let (sources, _) = load_sources(&path).expect("reload"); + assert!(sources.iter().any(|source| source.id == OFFICIAL_SOURCE_ID)); + cleanup(&root); + } + + #[test] + fn community_removal_tombstones_across_restarts() { + let root = unique_temp_root("tombstone"); + let path = sources_path(&root); + let remaining = remove_source(&path, COMMUNITY_SOURCE_ID).expect("community is removable"); + assert!(!remaining + .iter() + .any(|source| source.id == COMMUNITY_SOURCE_ID)); + let persisted = std::fs::read_to_string(&path).expect("store file"); + assert!( + persisted.contains(COMMUNITY_SOURCE_ID) && persisted.contains("removedPresetSourceIDs"), + "the removal tombstone is persisted: {persisted}" + ); + // A restart rebuilds from constants: Lithe Store stays removed. + let (sources, _) = load_sources(&path).expect("reload"); + assert!(!sources + .iter() + .any(|source| source.id == COMMUNITY_SOURCE_ID)); + assert!(sources.iter().any(|source| source.id == OFFICIAL_SOURCE_ID)); + cleanup(&root); + } + + #[test] + fn readding_community_restores_the_preset_identity() { + let root = unique_temp_root("readd"); + let path = sources_path(&root); + remove_source(&path, COMMUNITY_SOURCE_ID).expect("remove first"); + let sources = add_source(&path, "https://github.com/Lithe-IDEA/Lithe-store") + .expect("the store URL resolves back to the preset"); + assert_eq!( + sources + .iter() + .filter(|s| s.id == COMMUNITY_SOURCE_ID) + .count(), + 1, + "Lithe Store takes its preset ID back" + ); + let persisted = std::fs::read_to_string(&path).expect("store file"); + let file: Value = serde_json::from_str(&persisted).expect("valid JSON"); + assert!( + file.get("removedPresetSourceIDs").is_none(), + "the tombstone is cleared: {persisted}" + ); + let (reloaded, _) = load_sources(&path).expect("reload"); + assert_eq!( + reloaded + .iter() + .filter(|source| source.id == COMMUNITY_SOURCE_ID) + .count(), + 1, + "no duplicate stand-in after a restart" + ); + cleanup(&root); + } + + #[test] + fn hand_added_store_url_under_different_case_stands_in_for_the_preset() { + let root = unique_temp_root("standin"); + let path = sources_path(&root); + // Lithe Store added by hand before it became a preset, typed with a + // differently cased host: that entry keeps the ID its plugins + // recorded and stands in for the preset. + std::fs::create_dir_all(path.parent().expect("parent")).expect("store dir"); + std::fs::write( + &path, + json!({ + "schemaVersion": 1, + "sources": [{ + "id": "source-legacy1", + "name": "Lithe Store", + "input": "https://RAW.GitHubUserContent.com/Lithe-IDEA/Lithe-store/HEAD/lithe-plugin-index.json", + "indexURL": "https://RAW.GitHubUserContent.com/Lithe-IDEA/Lithe-store/HEAD/lithe-plugin-index.json", + "repository": "https://github.com/Lithe-IDEA/Lithe-store", + "isEnabled": true, + }], + }) + .to_string(), + ) + .expect("store file"); + let (sources, _) = load_sources(&path).expect("reload"); + assert_eq!(sources.len(), 2, "official plus the standing-in entry"); + assert!(sources.iter().any(|source| source.id == "source-legacy1")); + assert!( + !sources + .iter() + .any(|source| source.id == COMMUNITY_SOURCE_ID), + "the preset must not be appended next to its stand-in" + ); + cleanup(&root); + } + + #[test] + fn corrupted_store_is_preserved_and_reported() { + let root = unique_temp_root("corrupt"); + let path = sources_path(&root); + std::fs::create_dir_all(path.parent().expect("parent")).expect("store dir"); + std::fs::write(&path, "{ not json").expect("corrupt store"); + let (sources, problem) = load_sources(&path).expect("rebuild from presets"); + assert!(sources.len() == 2, "presets only"); + let diagnosis = problem.expect("the corruption is diagnosable"); + assert!( + diagnosis.contains("plugin.marketplace.sources.corrupt"), + "{diagnosis}" + ); + let corrupt_copy = path.with_extension("json.corrupt-1"); + assert_eq!( + std::fs::read_to_string(&corrupt_copy).expect("preserved bytes"), + "{ not json", + "the user's data is kept for diagnosis" + ); + assert!(!path.exists(), "the unreadable file moved aside"); + cleanup(&root); + } + + #[test] + fn exhausted_backup_slots_preserve_the_store_and_every_backup() { + let root = unique_temp_root("corrupt-full"); + let path = sources_path(&root); + std::fs::create_dir_all(path.parent().expect("parent")).expect("store dir"); + // Every backup slot holds distinct user data from earlier corruptions. + for attempt in 1..=MAXIMUM_CORRUPTION_BACKUPS { + std::fs::write( + path.with_extension(format!("json.corrupt-{attempt}")), + format!("BACKUP-{attempt}"), + ) + .expect("seed backup"); + } + std::fs::write(&path, "{ current corruption").expect("corrupt store"); + // No free slot remains: the load must fail with a diagnosable error + // instead of deleting any user data. + let failure = load_sources(&path).expect_err("no free backup slot"); + assert!( + failure.contains("plugin.marketplace.sources.corrupt"), + "{failure}" + ); + assert!(failure.contains("backup slots"), "{failure}"); + assert_eq!( + std::fs::read_to_string(&path).expect("original untouched"), + "{ current corruption", + "the unreadable store is never reset or overwritten" + ); + for attempt in 1..=MAXIMUM_CORRUPTION_BACKUPS { + assert_eq!( + std::fs::read_to_string(path.with_extension(format!("json.corrupt-{attempt}"))) + .expect("backup intact"), + format!("BACKUP-{attempt}"), + "backup {attempt} must be byte-identical" + ); + } + cleanup(&root); + } + + #[test] + fn failed_save_cleans_up_its_unique_temp_file() { + let root = unique_temp_root("save-failure"); + let path = sources_path(&root); + std::fs::create_dir_all(&path).expect("the store path is a directory, so rename fails"); + let (sources, _) = + load_sources(&path.parent().unwrap().join("elsewhere.json")).expect("presets"); + let failure = save_sources(&path, &sources).expect_err("renaming onto a directory fails"); + assert!(!failure.is_empty()); + let store_dir = path.parent().expect("parent"); + let leftovers: Vec<_> = std::fs::read_dir(store_dir) + .expect("store dir") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("sources.json.tmp") + }) + .collect(); + assert!( + leftovers.is_empty(), + "a failed save cleans up its own temp file: {leftovers:?}" + ); + assert!(path.is_dir(), "the failing destination is left as it was"); + cleanup(&root); + } + + #[test] + fn consecutive_corruptions_keep_every_original() { + let root = unique_temp_root("corrupt-chain"); + let path = sources_path(&root); + std::fs::create_dir_all(path.parent().expect("parent")).expect("store dir"); + // Two corruptions in a row must leave both originals on disk. + std::fs::write(&path, "{ first corruption").expect("first store"); + load_sources(&path).expect("first rebuild"); + std::fs::write(&path, "{ second corruption").expect("second store"); + load_sources(&path).expect("second rebuild"); + assert_eq!( + std::fs::read_to_string(path.with_extension("json.corrupt-1")).expect("first backup"), + "{ first corruption", + "the first user data survives the second corruption" + ); + assert_eq!( + std::fs::read_to_string(path.with_extension("json.corrupt-2")).expect("second backup"), + "{ second corruption", + ); + // A pre-existing backup file is never overwritten: the chain moves on. + std::fs::write(path.with_extension("json.corrupt-2"), "USER-DATA-KEPT") + .expect("seed backup"); + std::fs::write(&path, "{ third corruption").expect("third store"); + load_sources(&path).expect("third rebuild"); + assert_eq!( + std::fs::read_to_string(path.with_extension("json.corrupt-2")).expect("seeded backup"), + "USER-DATA-KEPT", + "an existing backup must never be overwritten" + ); + assert_eq!( + std::fs::read_to_string(path.with_extension("json.corrupt-3")).expect("third backup"), + "{ third corruption", + ); + cleanup(&root); + } + + /// Removes the temp root on success AND on failure paths, so a panicking + /// test never leaves a directory behind for a still-running worker. + struct TempRoot(PathBuf); + + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn concurrent_transactions_do_not_lose_updates() { + let root = TempRoot(unique_temp_root("concurrent")); + let path = sources_path(&root.0); + add_source(&path, "https://github.com/example/lithe-plugins").expect("seed one source"); + // Hold the host lock, then start another window's transaction on a + // separate thread. The worker signals when it is about to enter the + // transaction and again when it has finished; every wait is bounded. + let guard = crate::plugin_host::marketplace_sources::sources_lock() + .lock() + .expect("test holds the lock"); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let spawned_path = path.clone(); + let worker = std::thread::spawn(move || { + started_tx.send(()).expect("receiver alive"); + let result = add_source(&spawned_path, "https://github.com/other/lithe-plugins"); + // The completion signal is the worker's last action, so receiving + // it bounds the whole wait. + done_tx.send(()).expect("receiver alive"); + result + }); + started_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("the worker reached the transaction entry"); + // While the guard is held the transaction cannot complete: the bounded + // receive must time out instead of returning. A broken lock would make + // this return Ok and fail the test. + assert!( + done_rx + .recv_timeout(std::time::Duration::from_millis(1500)) + .is_err(), + "the transaction must wait for the host lock, not run concurrently" + ); + drop(guard); + done_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("the transaction finishes once the lock is released"); + worker + .join() + .expect("worker thread terminates after signaling done"); + let (sources, _) = load_sources(&path).expect("reload"); + assert_eq!( + sources.len(), + 4, + "the seeded source and the concurrent addition both survive" + ); + assert!(sources + .iter() + .any(|source| source.index_url == EXAMPLE_INDEX_URL)); + assert!(sources.iter().any(|source| { + source.index_url + == "https://raw.githubusercontent.com/other/lithe-plugins/HEAD/lithe-plugin-index.json" + })); + } + + #[test] + fn enabled_state_survives_preset_rebuild() { + let root = unique_temp_root("enabled"); + let path = sources_path(&root); + std::fs::create_dir_all(path.parent().expect("parent")).expect("store dir"); + std::fs::write( + &path, + json!({ + "schemaVersion": 1, + "sources": [ + { + "id": OFFICIAL_SOURCE_ID, + "name": "stale-name", + "input": "stale-input", + "indexURL": "stale-url", + "isEnabled": false, + }, + { + "id": COMMUNITY_SOURCE_ID, + "name": "stale-store", + "input": "stale-input", + "indexURL": "stale-url", + "isEnabled": false, + }, + { + "id": EXAMPLE_SOURCE_ID, + "name": "example/lithe-plugins", + "input": "https://github.com/example/lithe-plugins", + "indexURL": EXAMPLE_INDEX_URL, + "repository": "https://github.com/example/lithe-plugins", + "isEnabled": true, + }, + ], + }) + .to_string(), + ) + .expect("store file"); + let (sources, _) = load_sources(&path).expect("reload"); + let official = sources + .iter() + .find(|s| s.id == OFFICIAL_SOURCE_ID) + .expect("official"); + assert_eq!( + official.index_url, OFFICIAL_INDEX_URL, + "preset constants are rebuilt" + ); + assert_eq!( + official.name, "Lithe", + "the stored name never replaces the preset identity" + ); + assert!(!official.is_enabled, "only the enable state is the user's"); + let community = sources + .iter() + .find(|source| source.id == COMMUNITY_SOURCE_ID) + .expect("community"); + assert_eq!(community.index_url, COMMUNITY_INDEX_URL); + assert!(!community.is_enabled); + let example = sources + .iter() + .find(|s| s.id == EXAMPLE_SOURCE_ID) + .expect("kept"); + assert_eq!( + example.name, "example/lithe-plugins", + "user sources are kept verbatim" + ); + assert!(example.is_enabled); + cleanup(&root); + } + + #[test] + fn toggles_persist_atomically_without_temp_files() { + let root = unique_temp_root("toggle"); + let path = sources_path(&root); + set_enabled(&path, OFFICIAL_SOURCE_ID, false).expect("disable official"); + let persisted = std::fs::read_to_string(&path).expect("store file"); + assert!( + persisted.contains("\"isEnabled\": false"), + "the toggle is persisted: {persisted}" + ); + let store_dir = path.parent().expect("parent"); + let leftovers: Vec<_> = std::fs::read_dir(store_dir) + .expect("store dir") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("sources.json.tmp") + }) + .collect(); + assert!(leftovers.is_empty(), "no temp files remain: {leftovers:?}"); + let (reloaded, _) = load_sources(&path).expect("reload"); + let official = reloaded + .iter() + .find(|s| s.id == OFFICIAL_SOURCE_ID) + .expect("official"); + assert!(!official.is_enabled, "the disable survives a restart"); + assert!( + reloaded + .iter() + .any(|source| source.id == COMMUNITY_SOURCE_ID), + "official removal is impossible, so no tombstone exists" + ); + cleanup(&root); + } + + #[test] + fn save_sources_round_trips_through_the_persisted_shape() { + let root = unique_temp_root("roundtrip"); + let path = sources_path(&root); + let (sources, _) = load_sources(&path).expect("defaults"); + save_sources(&path, &sources).expect("save"); + let persisted = std::fs::read_to_string(&path).expect("store file"); + let file: Value = serde_json::from_str(&persisted).expect("valid JSON"); + assert_eq!(file["schemaVersion"], json!(1)); + let saved = file["sources"].as_array().expect("sources array"); + assert_eq!(saved.len(), 2); + assert_eq!(saved[0]["indexURL"], json!(OFFICIAL_INDEX_URL)); + assert_eq!(saved[1]["isEnabled"], json!(true)); + assert!( + file.get("removedPresetSourceIDs").is_none(), + "nothing removed yet, so the key is omitted like macOS" + ); + cleanup(&root); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/mod.rs b/windows/tauri/src-tauri/src/plugin_host/mod.rs index 4dbc22e3d5..2c0005c00c 100644 --- a/windows/tauri/src-tauri/src/plugin_host/mod.rs +++ b/windows/tauri/src-tauri/src/plugin_host/mod.rs @@ -8,11 +8,26 @@ //! capability rollout are recorded in //! .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md +pub mod build; +pub mod build_registry; +#[cfg(test)] +mod build_tests; +pub mod details; +#[cfg(test)] +mod details_tests; +pub(crate) mod build_resolver; +#[cfg(test)] +mod build_resolver_tests; pub mod install; #[cfg(test)] mod install_tests; pub mod launch; pub mod lifecycle; +pub mod marketplace_sources; +#[cfg(test)] +mod marketplace_sources_tests; +pub mod marketplace_download; +pub mod marketplace_index; pub mod run_plans; pub mod scan; pub mod settings; diff --git a/windows/tauri/src/features/bootstrap/bootstrap-async.ts b/windows/tauri/src/features/bootstrap/bootstrap-async.ts index 419f96e297..75573edd77 100644 --- a/windows/tauri/src/features/bootstrap/bootstrap-async.ts +++ b/windows/tauri/src/features/bootstrap/bootstrap-async.ts @@ -52,6 +52,17 @@ const asyncBootstrapSteps = [ await rescanDeclarativePlugins(); const { refreshDeclarativeUI } = await import("@/features/plugins/declarative-plugin-ui"); await refreshDeclarativeUI(); + const { startBuildSystemsRuntime } = await import( + "@/features/build-systems/build-systems-service" + ); + // Registers the plugin-lifecycle listener (catalog and registry + // revocation on disable/replace/uninstall) and warms the catalog. + // Detection runs when a build tool window mounts for the active + // workspace; the sync watcher lives with that window and is released + // when its last tool window unmounts. The returned cleanup is also + // wired to pagehide inside the service, so a destroyed window never + // keeps the listener alive. + await startBuildSystemsRuntime(); }, }, ] as const; diff --git a/windows/tauri/src/features/build-systems/actions/build-system-tool-window-actions.ts b/windows/tauri/src/features/build-systems/actions/build-system-tool-window-actions.ts new file mode 100644 index 0000000000..f84fa5748b --- /dev/null +++ b/windows/tauri/src/features/build-systems/actions/build-system-tool-window-actions.ts @@ -0,0 +1,18 @@ +import { applyRightToolWindowIntent } from "@/features/layout/actions/right-tool-window-actions"; +import { buildSystemViewId } from "../build-systems-store"; +import type { BuildCatalogEntry } from "../build-systems-types"; + +/** + * Tool-window actions for declarative build systems. The shell (docking, + * visibility) reuses the shared right tool-window registry; entries appear + * per enabled plugin build system and stay available with an empty state + * while no project is detected. + */ + +export function toggleBuildSystemWindow(entry: BuildCatalogEntry): void { + applyRightToolWindowIntent(buildSystemViewId(entry.pluginId, entry.buildSystemId), "toggle"); +} + +export function closeBuildSystemWindow(entry: BuildCatalogEntry): void { + applyRightToolWindowIntent(buildSystemViewId(entry.pluginId, entry.buildSystemId), "close"); +} diff --git a/windows/tauri/src/features/build-systems/build-systems-service.test.ts b/windows/tauri/src/features/build-systems/build-systems-service.test.ts new file mode 100644 index 0000000000..e95db861d4 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-systems-service.test.ts @@ -0,0 +1,514 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import type { BuildView } from "./build-systems-types"; + +/** + * Service tests for the declarative build bridge. Every request captures + * its initiating workspace's store and sequence; these tests pin the + * staleness contract across workspace switches, out-of-order responses, + * late failures, and plugin revocation. All waits are deferred promises the + * test resolves; nothing polls or sleeps. + */ + +const pendingDeferred = new Set(); +const cleanups: Array<() => void> = []; + +interface Deferred { + promise: Promise; + resolve: (value: unknown) => void; + reject: (error: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: unknown) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res as (value: unknown) => void; + reject = rej; + }); + const result = { promise, resolve, reject }; + pendingDeferred.add(result); + return result; +} + +const invokeCalls: Array<{ command: string; args: Record }> = []; +const deferredByCommand = new Map(); +let nextResult: unknown = null; +let nextCatalog: unknown = []; +let nextError: Error | null = null; + +function deferCommand(command: string): Deferred { + const entry = deferred(); + const queue = deferredByCommand.get(command) ?? []; + queue.push(entry); + deferredByCommand.set(command, queue); + return entry; +} + +mock.module("@/platform/tauri-core", () => ({ + invoke: mock(async (command: string, args?: Record) => { + invokeCalls.push({ command, args: args ?? {} }); + const queue = deferredByCommand.get(command); + if (queue && queue.length > 0) { + return queue.shift()!.promise; + } + if (command === "declarative_plugins_build_catalog") { + return nextCatalog; + } + if (nextError) { + throw nextError; + } + return nextResult; + }), +})); + +type LifecycleHandler = (event: { payload: unknown }) => void; +let lifecycleHandler: LifecycleHandler | null = null; +let fileChangeHandler: LifecycleHandler | null = null; + +mock.module("@tauri-apps/api/event", () => ({ + listen: mock(async (event: string, handler: LifecycleHandler) => { + if (event === "declarative-plugin-lifecycle") { + lifecycleHandler = handler; + } else if (event === "ln") { + fileChangeHandler = handler; + } + return () => { + if (event === "declarative-plugin-lifecycle" && lifecycleHandler === handler) { + lifecycleHandler = null; + } + if (event === "ln" && fileChangeHandler === handler) { + fileChangeHandler = null; + } + }; + }), +})); + +mock.module("@tauri-apps/api/window", () => ({ + // Co-running suites in one bun process also resolve types and classes + // from this module; provide the named exports they import so the mock + // stays import-compatible outside this file. + getCurrentWindow: () => ({ label: "test-window" }), + Window: class Window {}, + WebviewWindow: class WebviewWindow {}, + getAllWindows: async () => [], +})); + +const { workspaceRuntimeRegistry } = + await import("@/features/workspace/runtime/workspace-runtime-registry"); +const buildSystemsService = await import("./build-systems-service"); +const { + refreshBuildSystems, + setBuildOption, + subscribeBuildSyncWatcher, + startBuildSystemsRuntime, + useBuildSystemsCatalogStore, +} = buildSystemsService; +const { useBuildSystemsStore } = await import("./build-systems-store"); + +const WORKSPACE_A = "C:/work/a"; +const WORKSPACE_B = "C:/work/b"; + +function activateWorkspace(id: string, path: string | null): void { + if (path) { + workspaceRuntimeRegistry.activateWorkspace({ id, name: id, path }, "ready"); + } else { + workspaceRuntimeRegistry.activateWorkspace({ id, name: "Files" }, "ready"); + } +} + +function resetState(): void { + invokeCalls.length = 0; + deferredByCommand.clear(); + nextError = null; + nextCatalog = []; + nextResult = null; + for (const id of ["ws-a", "ws-b", "welcome"]) { + useBuildSystemsStore.getStore(id).getState().actions.reset(); + } + useBuildSystemsCatalogStore.setState({ entries: [] }); + lifecycleHandler = null; + fileChangeHandler = null; +} + +async function flushMicrotasks(): Promise { + // A fixed number of microtask turns drains the enqueue/invoke/apply chain; + // no timer, polling loop, or wall-clock wait participates in these tests. + for (let turn = 0; turn < 16; turn += 1) await Promise.resolve(); +} + +afterEach(async () => { + buildSystemsService.cleanupBuildSystemsRuntime?.(); + for (const cleanup of cleanups.splice(0)) cleanup(); + for (const pending of pendingDeferred) pending.resolve(null); + pendingDeferred.clear(); + await flushMicrotasks(); +}); + +const catalogEntry = { + pluginId: "dev.lithe.fixture.build-static-file", + pluginDisplayName: "Static File Build Fixture", + buildSystemId: "cargo", + buildSystemDisplayName: "Cargo", + icon: null, +}; + +function cargoView(workspaceRoot: string, generation: number): BuildView { + return { + generation, + workspaceRoot, + instances: [ + { + pluginID: "dev.lithe.fixture.build-static-file", + buildSystemID: "cargo", + displayName: "Cargo", + root: ".", + markers: ["Cargo.toml"], + modelSource: "static", + model: { projects: [{ name: "fixture-app", path: "." }] }, + stale: false, + syncDigest: null, + optionFingerprint: "fp", + options: [], + }, + ], + issues: [], + }; +} + +describe("refreshBuildSystems", () => { + test("forwards the workspace root without a forgeable window label", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextCatalog = [catalogEntry]; + nextResult = cargoView(WORKSPACE_A, 1); + + const entries = await refreshBuildSystems(); + + expect(entries).toEqual([catalogEntry]); + const refresh = invokeCalls.find( + (call) => call.command === "declarative_plugins_build_refresh", + ); + expect(refresh?.args).toEqual({ workspaceRoot: WORKSPACE_A }); + expect(useBuildSystemsStore.getStore("ws-a").getState().status).toBe("ready"); + }); + + test("a response landing after a switch is discarded before the next workspace detects", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const pendingA = deferCommand("declarative_plugins_build_refresh"); + const refreshA = refreshBuildSystems(); + await flushMicrotasks(); + + activateWorkspace("ws-b", WORKSPACE_B); + const pendingB = deferCommand("declarative_plugins_build_refresh"); + const refreshB = refreshBuildSystems(); + + pendingA.resolve(cargoView(WORKSPACE_A, 1)); + await refreshA; + await flushMicrotasks(); + pendingB.resolve(cargoView(WORKSPACE_B, 2)); + await refreshB; + + // A's old activation cannot publish a ready native session after a + // switch. B is dispatched after A finishes and owns the final session. + expect(useBuildSystemsStore.getStore("ws-a").getState().workspaceRoot).toBe(null); + expect(useBuildSystemsStore.getStore("ws-b").getState().generation).toBe(2); + expect(useBuildSystemsStore.getStore("ws-b").getState().workspaceRoot).toBe(WORKSPACE_B); + }); + + test("returning to a workspace drops the older of two in-flight refreshes", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const first = deferCommand("declarative_plugins_build_refresh"); + const refresh1 = refreshBuildSystems(); + await flushMicrotasks(); + + activateWorkspace("ws-b", WORKSPACE_B); + const refreshB = refreshBuildSystems(); + activateWorkspace("ws-a", WORKSPACE_A); + const second = deferCommand("declarative_plugins_build_refresh"); + const refresh2 = refreshBuildSystems(); + + first.resolve(cargoView(WORKSPACE_A, 4)); + await refresh1; + await refreshB; + expect(useBuildSystemsStore.getStore("ws-a").getState().generation).toBeNull(); + await flushMicrotasks(); + second.resolve(cargoView(WORKSPACE_A, 5)); + await refresh2; + + // The second request carried a newer sequence; the first is a late + // response and must not overwrite it. + expect(useBuildSystemsStore.getStore("ws-a").getState().generation).toBe(5); + }); + + test("a failed request does not strand the next queued refresh", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const failing = deferCommand("declarative_plugins_build_refresh"); + const refresh1 = refreshBuildSystems(); + await flushMicrotasks(); + + nextCatalog = []; + nextResult = cargoView(WORKSPACE_A, 2); + const refresh2 = refreshBuildSystems(); + + failing.reject(new Error("plugin.build.invalidSelection: late boom")); + await refresh1; + await refresh2; + + const state = useBuildSystemsStore.getStore("ws-a").getState(); + expect(state.status).toBe("ready"); + expect(state.error).toBeNull(); + }); + + test("a failure for the latest sequence surfaces in the store", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextError = new Error("plugin.build.invalidSelection: boom"); + await refreshBuildSystems(); + + const state = useBuildSystemsStore.getStore("ws-a").getState(); + expect(state.status).toBe("failed"); + expect(state.error).toContain("plugin.build.invalidSelection"); + }); + + test("without a workspace the store resets and the host is not called", async () => { + resetState(); + activateWorkspace("welcome", null); + + await refreshBuildSystems(); + + expect( + invokeCalls.find((call) => call.command === "declarative_plugins_build_refresh"), + ).toBeUndefined(); + expect(useBuildSystemsStore.getStore("welcome").getState().status).toBe("idle"); + }); +}); + +describe("setBuildOption", () => { + test("forwards the edit and applies the returned view", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextResult = cargoView(WORKSPACE_A, 1); + await refreshBuildSystems(); + invokeCalls.length = 0; + nextResult = cargoView(WORKSPACE_A, 2); + + await setBuildOption("dev.lithe.fixture.build-static-file", "cargo", ".", "release", true); + + const edit = invokeCalls[0]!; + expect(edit.command).toBe("declarative_plugins_build_set_option"); + expect(edit.args).toEqual({ + workspaceRoot: WORKSPACE_A, + pluginId: "dev.lithe.fixture.build-static-file", + buildSystemId: "cargo", + root: ".", + optionId: "release", + value: true, + }); + expect(useBuildSystemsStore.getStore("ws-a").getState().generation).toBe(2); + }); + + test("a late edit failure lands in the initiating store with its sequence", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextResult = cargoView(WORKSPACE_A, 1); + await refreshBuildSystems(); + + const pending = deferCommand("declarative_plugins_build_set_option"); + const edit = setBuildOption( + "dev.lithe.fixture.build-static-file", + "cargo", + ".", + "release", + true, + ).catch(() => {}); + pending.reject(new Error("rejected by Core")); + await edit; + + const state = useBuildSystemsStore.getStore("ws-a").getState(); + expect(state.status).toBe("failed"); + expect(state.error).toContain("rejected by Core"); + }); +}); + +describe("plugin revocation", () => { + test("the lifecycle listener filters the catalog and revokes the registry", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextCatalog = [catalogEntry]; + nextResult = cargoView(WORKSPACE_A, 1); + await refreshBuildSystems(); + expect(useBuildSystemsCatalogStore.getState().entries).toEqual([catalogEntry]); + const background = useBuildSystemsStore.getStore("ws-b"); + const backgroundRequest = background.getState().actions.beginRequest(); + background.getState().actions.applyView(cargoView(WORKSPACE_B, 1), backgroundRequest); + + invokeCalls.length = 0; + await startBuildSystemsRuntime(); + expect(lifecycleHandler).not.toBeNull(); + lifecycleHandler!({ + payload: { pluginId: "dev.lithe.fixture.build-static-file", active: false }, + }); + // Revoke runs async; flush the microtask queue. + await flushMicrotasks(); + + expect(useBuildSystemsCatalogStore.getState().entries).toEqual([]); + expect(useBuildSystemsStore.getStore("ws-a").getState().windows).toEqual({}); + expect( + useBuildSystemsStore.getStore("ws-b").getState().windows, + "non-active workspace stores are revoked too", + ).toEqual({}); + const revoke = invokeCalls.find( + (call) => call.command === "declarative_plugins_build_plugins_changed", + ); + expect(revoke?.args).toEqual({ + pluginId: "dev.lithe.fixture.build-static-file", + }); + buildSystemsService.cleanupBuildSystemsRuntime?.(); + }); + + test("refresh, option, and sync responses started before disable cannot revive the plugin", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextCatalog = [catalogEntry]; + nextResult = cargoView(WORKSPACE_A, 1); + const cleanupRuntime = await startBuildSystemsRuntime(); + await flushMicrotasks(); + + const refreshResponse = deferCommand("declarative_plugins_build_refresh"); + const refresh = refreshBuildSystems(); + await flushMicrotasks(); + expect(invokeCalls[invokeCalls.length - 1]?.command).toBe("declarative_plugins_build_refresh"); + lifecycleHandler!({ + payload: { pluginId: "dev.lithe.fixture.build-static-file", active: false }, + }); + await flushMicrotasks(); + refreshResponse.resolve(cargoView(WORKSPACE_A, 2)); + await refresh; + expect(useBuildSystemsStore.getStore("ws-a").getState().windows).toEqual({}); + + lifecycleHandler!({ + payload: { pluginId: "dev.lithe.fixture.build-static-file", active: true }, + }); + nextResult = cargoView(WORKSPACE_A, 3); + await refreshBuildSystems(); + const optionResponse = deferCommand("declarative_plugins_build_set_option"); + const option = setBuildOption( + "dev.lithe.fixture.build-static-file", + "cargo", + ".", + "release", + true, + ); + await flushMicrotasks(); + expect(invokeCalls[invokeCalls.length - 1]?.command).toBe( + "declarative_plugins_build_set_option", + ); + lifecycleHandler!({ + payload: { pluginId: "dev.lithe.fixture.build-static-file", active: false }, + }); + await flushMicrotasks(); + optionResponse.resolve(cargoView(WORKSPACE_A, 4)); + await option; + expect(useBuildSystemsStore.getStore("ws-a").getState().windows).toEqual({}); + + lifecycleHandler!({ + payload: { pluginId: "dev.lithe.fixture.build-static-file", active: true }, + }); + nextResult = cargoView(WORKSPACE_A, 5); + await refreshBuildSystems(); + let scheduledCheck: (() => void) | null = null; + const releaseWatcher = await subscribeBuildSyncWatcher((callback) => { + scheduledCheck = callback; + return () => { + scheduledCheck = null; + }; + }); + cleanups.push(releaseWatcher); + const syncResponse = deferCommand("declarative_plugins_build_sync_check"); + fileChangeHandler!({ payload: { path: "Cargo.toml", event_type: "reloaded" } }); + scheduledCheck!(); + await flushMicrotasks(); + expect(invokeCalls[invokeCalls.length - 1]?.command).toBe( + "declarative_plugins_build_sync_check", + ); + lifecycleHandler!({ + payload: { pluginId: "dev.lithe.fixture.build-static-file", active: false }, + }); + await flushMicrotasks(); + syncResponse.resolve(cargoView(WORKSPACE_A, 6)); + await flushMicrotasks(); + expect(useBuildSystemsStore.getStore("ws-a").getState().windows).toEqual({}); + + cleanupRuntime(); + }); + + test("a catalog fetch that started before a disable never overwrites it", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const staleFetch = deferCommand("declarative_plugins_build_catalog"); + const fetchPromise = refreshBuildSystems(); + + // The post-disable catalog lands first with the plugin removed. + nextCatalog = []; + const { fetchBuildCatalog: fetchAgain } = await import("./build-systems-service"); + const fresh = await fetchAgain(); + expect(fresh).toEqual([]); + expect(useBuildSystemsCatalogStore.getState().entries).toEqual([]); + + staleFetch.resolve([catalogEntry]); + await fetchPromise; + expect(useBuildSystemsCatalogStore.getState().entries).toEqual([]); + }); +}); + +describe("workspace activation", () => { + test("returning A to B to A realigns native commands before edits and sync", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + nextResult = cargoView(WORKSPACE_A, 1); + const cleanupRuntime = await startBuildSystemsRuntime(); + await flushMicrotasks(); + + nextResult = cargoView(WORKSPACE_B, 2); + activateWorkspace("ws-b", WORKSPACE_B); + await flushMicrotasks(); + nextResult = cargoView(WORKSPACE_A, 3); + activateWorkspace("ws-a", WORKSPACE_A); + await flushMicrotasks(); + + const refreshRoots = invokeCalls + .filter((call) => call.command === "declarative_plugins_build_refresh") + .map((call) => call.args.workspaceRoot); + expect(refreshRoots).toEqual([WORKSPACE_A, WORKSPACE_B, WORKSPACE_A]); + expect(useBuildSystemsStore.getStore("ws-a").getState().status).toBe("ready"); + + nextResult = cargoView(WORKSPACE_A, 4); + await setBuildOption("dev.lithe.fixture.build-static-file", "cargo", ".", "release", true); + const edit = [...invokeCalls] + .reverse() + .find((call) => call.command === "declarative_plugins_build_set_option"); + expect(edit?.args.workspaceRoot).toBe(WORKSPACE_A); + + let scheduledCheck: (() => void) | null = null; + const releaseWatcher = await subscribeBuildSyncWatcher((callback) => { + scheduledCheck = callback; + return () => { + scheduledCheck = null; + }; + }); + cleanups.push(releaseWatcher); + nextResult = cargoView(WORKSPACE_A, 5); + fileChangeHandler!({ payload: { path: "Cargo.toml", event_type: "reloaded" } }); + scheduledCheck!(); + await flushMicrotasks(); + const sync = [...invokeCalls] + .reverse() + .find((call) => call.command === "declarative_plugins_build_sync_check"); + expect(sync?.args.workspaceRoot).toBe(WORKSPACE_A); + + cleanupRuntime(); + }); +}); diff --git a/windows/tauri/src/features/build-systems/build-systems-service.ts b/windows/tauri/src/features/build-systems/build-systems-service.ts new file mode 100644 index 0000000000..4021fa2125 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-systems-service.ts @@ -0,0 +1,400 @@ +import { create, type StoreApi } from "zustand"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { invoke } from "@/platform/tauri-core"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import type { BuildCatalogEntry, BuildView } from "./build-systems-types"; +import { + BUILD_SYSTEMS_STORE_KEY, + useBuildSystemsStore, + type BuildSystemsState, +} from "./build-systems-store"; + +/** + * Host bridge for the declarative build registry. The Rust side + * (`plugin_host/build.rs`) owns detection, option semantics, sync digests, + * and the trust gate; this service only forwards calls and guards late + * responses. Every request captures its initiating workspace's store and a + * sequence number, so a response that lands after a workspace switch, an + * out-of-order refresh, or a plugin disable can never write another + * workspace's store or revive a revoked window. + */ + +interface FileChangeEvent { + path: string; + event_type: "opened" | "reloaded" | "deleted" | "rescan"; +} + +interface PluginLifecyclePayload { + pluginId: string; + active: boolean; + operationId?: string; +} + +/** Milliseconds of file-change quiet before one sync check runs. */ +export const BUILD_SYNC_DEBOUNCE_MS = 500; + +/** Catalog of declared build systems, for activity-rail registration. */ +export const useBuildSystemsCatalogStore = create<{ entries: BuildCatalogEntry[] }>(() => ({ + entries: [], +})); + +// Catalog responses are sequenced like build views: a fetch that started +// before a plugin disable must not overwrite the post-disable catalog. +let catalogSequence = 0; +let lastCatalogSequence = 0; + +// The host stores one current workspace per product window. Dispatch registry +// operations in initiation order so a delayed B refresh cannot run after the +// refresh that reactivates A and silently move the native session back to B. +let registryTail: Promise = Promise.resolve(); +let activeWorkspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); +let activationEpoch = 0; + +function captureActivationEpoch(): number { + const nextId = workspaceRuntimeRegistry.getActiveWorkspaceId(); + if (nextId !== activeWorkspaceId) { + activeWorkspaceId = nextId; + activationEpoch += 1; + } + return activationEpoch; +} + +function enqueueRegistryOperation(operation: () => Promise): Promise { + const result = registryTail.then(operation); + registryTail = result.catch(() => {}); + return result; +} + +function applyCatalog(entries: BuildCatalogEntry[], sequence: number): void { + if (sequence < lastCatalogSequence) { + return; + } + lastCatalogSequence = sequence; + useBuildSystemsCatalogStore.setState({ entries }); +} + +export async function fetchBuildCatalog(): Promise { + const sequence = ++catalogSequence; + const entries = await invoke("declarative_plugins_build_catalog"); + applyCatalog(entries, sequence); + return entries; +} + +/** Current workspace root for this window, or null on the welcome screen. */ +export function currentWorkspaceRoot(): string | null { + return workspaceRuntimeRegistry.getActiveWorkspace()?.descriptor.path ?? null; +} + +/** + * The store of the workspace a request is initiated from. Captured before + * the await, so a completion never writes whichever workspace happens to be + * active by then; a closed workspace's detached store simply absorbs + * nothing the user can see. + */ +function initiatingStore(): { + workspaceId: string; + store: StoreApi; + workspaceRoot: string | null; + isCurrent: () => boolean; +} { + const workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); + const epoch = captureActivationEpoch(); + return { + workspaceId, + store: useBuildSystemsStore.getStore(workspaceId), + workspaceRoot: currentWorkspaceRoot(), + isCurrent: () => + epoch === captureActivationEpoch() && + workspaceId === workspaceRuntimeRegistry.getActiveWorkspaceId(), + }; +} + +function failureMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Detects build-system instances for the current workspace and applies the + * view to the initiating workspace's store. Safe to call concurrently. + */ +export async function refreshBuildSystems(): Promise { + const { workspaceId, store, workspaceRoot, isCurrent } = initiatingStore(); + if (!workspaceRoot) { + store.getState().actions.reset(); + return fetchBuildCatalog(); + } + const sequence = store.getState().actions.beginRequest(); + const [catalog] = await Promise.all([ + fetchBuildCatalog(), + enqueueRegistryOperation(() => + isCurrent() + ? invoke("declarative_plugins_build_refresh", { workspaceRoot }) + : Promise.resolve(null), + ) + .then((view) => { + if (view && isCurrent()) store.getState().actions.applyView(view, sequence); + }) + .catch((error: unknown) => { + if (isCurrent()) store.getState().actions.fail(failureMessage(error), sequence); + }), + ]); + // A workspace removed while the request ran leaves its store detached; + // only report the catalog for callers that are still current. + return workspaceId === workspaceRuntimeRegistry.getActiveWorkspaceId() ? catalog : []; +} + +export async function setBuildOption( + pluginId: string, + buildSystemId: string, + root: string, + optionId: string, + value: unknown, +): Promise { + const { store, workspaceRoot, isCurrent } = initiatingStore(); + if (!workspaceRoot) { + return; + } + const sequence = store.getState().actions.beginRequest(); + const view = await enqueueRegistryOperation(() => + isCurrent() + ? invoke("declarative_plugins_build_set_option", { + workspaceRoot, + pluginId, + buildSystemId, + root, + optionId, + value: value ?? null, + }) + : Promise.resolve(null), + ).catch((error: unknown) => { + if (isCurrent()) store.getState().actions.fail(failureMessage(error), sequence); + throw error; + }); + if (view && isCurrent()) store.getState().actions.applyView(view, sequence); +} + +async function syncInstance(pluginId: string, buildSystemId: string, root: string): Promise { + const { store, workspaceRoot, isCurrent } = initiatingStore(); + if (!workspaceRoot) { + return; + } + const sequence = store.getState().actions.beginRequest(); + const view = await enqueueRegistryOperation(() => + isCurrent() + ? invoke("declarative_plugins_build_sync_check", { + workspaceRoot, + pluginId, + buildSystemId, + root, + }) + : Promise.resolve(null), + ).catch((error: unknown) => { + if (isCurrent()) store.getState().actions.fail(failureMessage(error), sequence); + throw error; + }); + if (view && isCurrent()) store.getState().actions.applyView(view, sequence); +} + +/** + * Watches workspace file changes and re-checks every instance's sync + * fingerprint after a debounce, mirroring the macOS rule that saves within + * the window collapse into one check. Returns the unlisten function; the + * tool window owns this subscription and cleans it up on unmount. + */ +export async function subscribeBuildSyncWatcher( + schedule: (callback: () => void, delayMs: number) => () => void = (callback, delayMs) => { + const handle = setTimeout(callback, delayMs); + return () => clearTimeout(handle); + }, +): Promise { + let pending: (() => void) | null = null; + let timer: (() => void) | null = null; + const runChecks = () => { + pending = null; + if (!currentWorkspaceRoot()) { + return; + } + // Read the active workspace's store at fire time; a workspace switch + // during the debounce checks the new workspace's instances only. + const state = useBuildSystemsStore.getState(); + if (state.status !== "ready") { + return; + } + for (const window of Object.values(state.windows)) { + for (const instance of window.instances) { + syncInstance(instance.pluginID, instance.buildSystemID, instance.root).catch(() => { + // A failed sync (workspace mid-switch, plugin disabled) keeps the + // last state; the next change or manual refresh retries. + }); + } + } + }; + const unlisten = await listen("ln", (event) => { + if (!currentWorkspaceRoot()) { + return; + } + pending = runChecks; + timer?.(); + timer = schedule(() => { + timer = null; + pending?.(); + }, BUILD_SYNC_DEBOUNCE_MS); + }); + return () => { + timer?.(); + pending = null; + unlisten(); + }; +} + +/** Refcounted per-mount sync watcher shared by every build tool window. */ +let watcherConsumers = 0; +let watcherUnlisten: UnlistenFn | null = null; +let watcherAcquisition: Promise | null = null; + +export async function acquireBuildSyncWatcher(): Promise<() => void> { + watcherConsumers += 1; + if (!watcherUnlisten && !watcherAcquisition) { + watcherAcquisition = subscribeBuildSyncWatcher().then((unlisten) => { + watcherUnlisten = unlisten; + watcherAcquisition = null; + }); + } + await watcherAcquisition; + return () => { + watcherConsumers -= 1; + if (watcherConsumers === 0 && watcherUnlisten) { + watcherUnlisten(); + watcherUnlisten = null; + } + }; +} + +/** + * Revokes one plugin everywhere: its catalog entries, every instantiated + * workspace store's window state, and the native registry sessions. The + * frontend lifecycle listener calls this when a plugin is disabled, + * replaced, or uninstalled. + */ +async function revokePlugin(pluginId: string): Promise { + const sequence = ++catalogSequence; + applyCatalog( + useBuildSystemsCatalogStore.getState().entries.filter((entry) => entry.pluginId !== pluginId), + sequence, + ); + for (const store of workspaceRuntimeRegistry.getExistingStores( + BUILD_SYSTEMS_STORE_KEY, + )) { + store.getState().actions.dropPlugin(pluginId); + } + await enqueueRegistryOperation(() => + invoke("declarative_plugins_build_plugins_changed", { pluginId }), + ).catch(() => { + // The native revoke is best-effort from the listener; every later + // command re-checks the package against the trust gate anyway. + }); +} + +function allowPluginEverywhere(pluginId: string): void { + for (const store of workspaceRuntimeRegistry.getExistingStores( + BUILD_SYSTEMS_STORE_KEY, + )) { + store.getState().actions.allowPlugin(pluginId); + } +} + +/** Refcounted plugin-lifecycle listener shared by the bootstrap runtime. */ +let lifecycleConsumers = 0; +let lifecycleUnlisten: UnlistenFn | null = null; +let lifecycleAcquisition: Promise | null = null; + +async function acquireLifecycleListener(): Promise<() => void> { + lifecycleConsumers += 1; + if (!lifecycleUnlisten && !lifecycleAcquisition) { + lifecycleAcquisition = listen( + "declarative-plugin-lifecycle", + ({ payload }) => { + if (payload.active) { + allowPluginEverywhere(payload.pluginId); + void refreshBuildSystems().catch(() => {}); + return; + } + void revokePlugin(payload.pluginId); + }, + // Same targeting rule as the other declarative listeners: only this + // product window drains its own lifecycle events. + { target: { kind: "Window", label: getCurrentWindow().label } }, + ).then((unlisten) => { + lifecycleUnlisten = unlisten; + lifecycleAcquisition = null; + }); + } + await lifecycleAcquisition; + return () => { + lifecycleConsumers -= 1; + if (lifecycleConsumers === 0 && lifecycleUnlisten) { + lifecycleUnlisten(); + lifecycleUnlisten = null; + } + }; +} + +/** Cleanup registered by the bootstrap runtime; see startBuildSystemsRuntime. */ +export let cleanupBuildSystemsRuntime: (() => void) | null = null; + +/** + * Keeps the window-owned native registry aligned with the active workspace. + * Stores retain their ready views across tab switches, but the Rust registry + * intentionally has only one current workspace per window, so every actual + * activation must re-detect once even when the returning store is ready. + */ +function subscribeWorkspaceActivations(): () => void { + let observedWorkspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); + return workspaceRuntimeRegistry.subscribe(() => { + const nextWorkspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); + if (nextWorkspaceId === observedWorkspaceId) { + return; + } + observedWorkspaceId = nextWorkspaceId; + captureActivationEpoch(); + void refreshBuildSystems().catch(() => {}); + }); +} + +/** + * Bootstrap entry: registers the plugin-lifecycle listener (catalog and + * registry revocation) and warms the catalog. Returns the cleanup; the page + * also cleans up on hide, so a destroyed window never keeps the listener. + */ +export async function startBuildSystemsRuntime(): Promise<() => void> { + const releaseLifecycle = await acquireLifecycleListener(); + const releaseWorkspaceActivations = subscribeWorkspaceActivations(); + // `window` is absent under the bun test runner; the cleanup stays + // available for callers there. + const hasWindow = typeof window !== "undefined"; + let cleaned = false; + const cleanup = () => { + if (cleaned) { + return; + } + cleaned = true; + if (hasWindow) { + window.removeEventListener("pagehide", onPageHide); + } + releaseWorkspaceActivations(); + releaseLifecycle(); + if (cleanupBuildSystemsRuntime === cleanup) { + cleanupBuildSystemsRuntime = null; + } + }; + const onPageHide = () => cleanup(); + if (hasWindow) { + window.addEventListener("pagehide", onPageHide, { once: true }); + } + cleanupBuildSystemsRuntime = cleanup; + // Align the native window session on startup as well as warming the + // catalog. The welcome workspace path still skips native detection. + void refreshBuildSystems().catch(() => {}); + return cleanup; +} diff --git a/windows/tauri/src/features/build-systems/build-systems-store.test.ts b/windows/tauri/src/features/build-systems/build-systems-store.test.ts new file mode 100644 index 0000000000..8235961398 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-systems-store.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, test } from "bun:test"; +import { + buildSystemViewId, + parseBuildSystemViewId, + useBuildSystemsStore, +} from "./build-systems-store"; +import type { BuildView } from "./build-systems-types"; + +/** + * Store tests for the build tool-window display state: grouping, selection + * preservation, request-sequence guards, plugin revocation, and instance + * selection validation. Pure state transitions with no timers and no host + * calls. + */ + +function view(generation: number, roots: string[], selectedIssue = false): BuildView { + return { + generation, + workspaceRoot: "C:/work/demo", + instances: roots.map((root) => ({ + pluginID: "dev.lithe.fixture.build-static-file", + buildSystemID: "cargo", + displayName: "Cargo", + root, + markers: ["Cargo.toml"], + modelSource: "static", + model: { projects: [{ name: "fixture-app", path: root }] }, + stale: false, + syncDigest: null, + optionFingerprint: "fp", + options: [], + })), + issues: selectedIssue ? [{ kind: "shadowed", pluginID: "p2", by: "p1" }] : [], + }; +} + +const VIEW_ID = buildSystemViewId("dev.lithe.fixture.build-static-file", "cargo"); +const request = (sequence: number, revocationEpoch = 0) => ({ + sequence, + revocationEpoch, +}); + +describe("build systems store", () => { + test("groups instances of one build system into a single window", () => { + useBuildSystemsStore.getState().actions.reset(); + useBuildSystemsStore.getState().actions.applyView(view(1, ["a", "b"]), request(1)); + + const state = useBuildSystemsStore.getState(); + expect(Object.keys(state.windows)).toEqual([VIEW_ID]); + expect(state.windows[VIEW_ID]!.instances.map((instance) => instance.root)).toEqual(["a", "b"]); + expect(state.windows[VIEW_ID]!.selectedRoot).toBe("a"); + expect(state.status).toBe("ready"); + }); + + test("the instance selection survives refreshes and falls back when removed", () => { + useBuildSystemsStore.getState().actions.reset(); + useBuildSystemsStore.getState().actions.applyView(view(1, ["a", "b"]), request(1)); + useBuildSystemsStore.getState().actions.selectInstance(VIEW_ID, "b"); + expect(useBuildSystemsStore.getState().windows[VIEW_ID]!.selectedRoot).toBe("b"); + + useBuildSystemsStore.getState().actions.applyView(view(2, ["a", "b"]), request(2)); + expect(useBuildSystemsStore.getState().windows[VIEW_ID]!.selectedRoot).toBe("b"); + + useBuildSystemsStore.getState().actions.applyView(view(3, ["b"]), request(3)); + expect(useBuildSystemsStore.getState().windows[VIEW_ID]!.selectedRoot).toBe("b"); + + useBuildSystemsStore.getState().actions.applyView(view(4, ["c"]), request(4)); + expect(useBuildSystemsStore.getState().windows[VIEW_ID]!.selectedRoot).toBe("c"); + }); + + test("an unknown instance root is not selectable", () => { + useBuildSystemsStore.getState().actions.reset(); + useBuildSystemsStore.getState().actions.applyView(view(1, ["a"]), request(1)); + + useBuildSystemsStore.getState().actions.selectInstance(VIEW_ID, "not-a-root"); + expect(useBuildSystemsStore.getState().windows[VIEW_ID]!.selectedRoot).toBe("a"); + }); + + test("a workspace change discards the previous view", () => { + useBuildSystemsStore.getState().actions.reset(); + useBuildSystemsStore.getState().actions.applyView(view(2, ["a"]), request(2)); + + const switched = { ...view(1, ["b"]), workspaceRoot: "C:/work/other" }; + useBuildSystemsStore.getState().actions.applyView(switched, request(3)); + + const state = useBuildSystemsStore.getState(); + expect(state.generation).toBe(2); + expect(state.windows[VIEW_ID]!.instances[0]!.root).toBe("a"); + }); + + test("out-of-order responses are dropped by request sequence", () => { + useBuildSystemsStore.getState().actions.reset(); + // Sequence 2 applies first. + useBuildSystemsStore.getState().actions.applyView(view(5, ["new"]), request(2)); + // Sequence 1 is a late response and must not overwrite it. + useBuildSystemsStore.getState().actions.applyView(view(4, ["old"]), request(1)); + + const state = useBuildSystemsStore.getState(); + expect(state.generation).toBe(5); + expect(state.windows[VIEW_ID]!.instances[0]!.root).toBe("new"); + }); + + test("a late failure never clobbers a newer applied view", () => { + useBuildSystemsStore.getState().actions.reset(); + const { beginRequest, applyView, fail } = useBuildSystemsStore.getState().actions; + const first = beginRequest(); + applyView(view(3, ["a"]), first); + const second = beginRequest(); + applyView(view(4, ["b"]), second); + fail("late boom", first); + + const state = useBuildSystemsStore.getState(); + expect(state.status).toBe("ready"); + expect(state.error).toBeNull(); + expect(state.windows[VIEW_ID]!.instances[0]!.root).toBe("b"); + }); + + test("dropPlugin removes that plugin's windows and issues only", () => { + useBuildSystemsStore.getState().actions.reset(); + const otherInstance = { + pluginID: "dev.lithe.other", + buildSystemID: "gradle", + displayName: "Gradle", + root: "a", + markers: [], + modelSource: "static" as const, + model: null, + stale: false, + syncDigest: null, + optionFingerprint: null, + options: [], + }; + const mixed = view(1, ["a"], true); + useBuildSystemsStore.getState().actions.applyView( + { + ...mixed, + instances: [...mixed.instances, otherInstance], + issues: [ + ...mixed.issues, + { + kind: "rejected" as const, + pluginID: "dev.lithe.other", + error: { code: "x", message: "y" }, + }, + ], + }, + request(1), + ); + + useBuildSystemsStore.getState().actions.dropPlugin("dev.lithe.fixture.build-static-file"); + + const state = useBuildSystemsStore.getState(); + expect(Object.keys(state.windows)).toEqual([buildSystemViewId("dev.lithe.other", "gradle")]); + expect( + state.issues.every((issue) => issue.pluginID !== "dev.lithe.fixture.build-static-file"), + ).toBe(true); + }); + + test("issues ride along with the view", () => { + useBuildSystemsStore.getState().actions.reset(); + useBuildSystemsStore.getState().actions.applyView(view(1, ["a"], true), request(1)); + expect(useBuildSystemsStore.getState().issues).toEqual([ + { kind: "shadowed", pluginID: "p2", by: "p1" }, + ]); + }); + + test("a response crossing plugin revocation cannot revive that plugin", () => { + useBuildSystemsStore.getState().actions.reset(); + const started = useBuildSystemsStore.getState().actions.beginRequest(); + + useBuildSystemsStore.getState().actions.dropPlugin("dev.lithe.fixture.build-static-file"); + useBuildSystemsStore.getState().actions.applyView(view(1, ["late"]), started); + + const state = useBuildSystemsStore.getState(); + expect(state.windows[VIEW_ID]).toBeUndefined(); + expect(state.status).toBe("ready"); + }); + + test("revocation filters only its plugin from a late complete view", () => { + useBuildSystemsStore.getState().actions.reset(); + const started = useBuildSystemsStore.getState().actions.beginRequest(); + const other = { + ...view(1, ["revoked"]).instances[0]!, + pluginID: "dev.lithe.other", + buildSystemID: "gradle", + displayName: "Gradle", + root: "kept", + }; + + useBuildSystemsStore.getState().actions.dropPlugin("dev.lithe.fixture.build-static-file"); + useBuildSystemsStore + .getState() + .actions.applyView( + { ...view(1, ["revoked"]), instances: [...view(1, ["revoked"]).instances, other] }, + started, + ); + + const state = useBuildSystemsStore.getState(); + expect(state.windows[VIEW_ID]).toBeUndefined(); + expect(state.windows[buildSystemViewId("dev.lithe.other", "gradle")]?.instances[0]?.root).toBe( + "kept", + ); + }); + + test("a failure crossing revocation cannot replace the remaining plugin state", () => { + useBuildSystemsStore.getState().actions.reset(); + const current = useBuildSystemsStore.getState().actions.beginRequest(); + useBuildSystemsStore.getState().actions.applyView(view(1, ["a"]), current); + const late = useBuildSystemsStore.getState().actions.beginRequest(); + + useBuildSystemsStore.getState().actions.dropPlugin("dev.lithe.unrelated-disabled-plugin"); + useBuildSystemsStore.getState().actions.fail("late failure", late); + + const state = useBuildSystemsStore.getState(); + expect(state.status).toBe("ready"); + expect(state.error).toBeNull(); + expect(state.windows[VIEW_ID]).toBeDefined(); + }); + + test("view ids round-trip through the parser", () => { + expect(parseBuildSystemViewId(VIEW_ID)).toEqual({ + pluginId: "dev.lithe.fixture.build-static-file", + buildSystemId: "cargo", + }); + expect(parseBuildSystemViewId("maven")).toBeNull(); + expect(parseBuildSystemViewId("build-system:only-one-part")).toBeNull(); + expect(parseBuildSystemViewId("build-system:a:")).toBeNull(); + expect(parseBuildSystemViewId("build-system::b")).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/build-systems/build-systems-store.ts b/windows/tauri/src/features/build-systems/build-systems-store.ts new file mode 100644 index 0000000000..4198c73c80 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-systems-store.ts @@ -0,0 +1,244 @@ +import { create } from "zustand"; +import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; +import type { BuildIssue, BuildView, BuildWindowState } from "./build-systems-types"; + +export const BUILD_SYSTEMS_STORE_KEY = "build-systems"; + +export interface BuildRequestVersion { + sequence: number; + /** Store-wide revocation clock captured before the native call starts. */ + revocationEpoch: number; +} + +/** + * Display state for the declarative build tool windows, scoped to one + * workspace. The Rust registry owns the build facts; this store only + * mirrors the latest view and the user's instance selection. + */ + +export interface BuildSystemsState { + status: "idle" | "loading" | "ready" | "failed"; + generation: number | null; + workspaceRoot: string | null; + error: string | null; + issues: BuildIssue[]; + windows: Record; + /** + * Monotonic sequence of requests this workspace's store issued. Responses + * carry the sequence they were initiated with; anything older than the + * last applied sequence is a late or out-of-order response and is dropped, + * whether it reports success or failure. + */ + requestSequence: number; + lastAppliedSequence: number; + /** Monotonic clock used to identify requests that predate a plugin revoke. */ + revocationEpoch: number; + /** Last revoke clock per plugin, retained across a later re-enable. */ + pluginRevocationEpochs: Record; + /** Plugins that remain revoked until an active lifecycle event arrives. */ + revokedPlugins: Record; + actions: { + /** Marks a request in flight and returns its sequence and revoke clock. */ + beginRequest: () => BuildRequestVersion; + applyView: (view: BuildView, request: BuildRequestVersion) => void; + fail: (message: string, request: BuildRequestVersion) => void; + selectInstance: (viewId: string, root: string) => void; + /** Drops one disabled plugin and invalidates requests started before it. */ + dropPlugin: (pluginId: string) => void; + /** Allows post-enable requests to return the plugin again. */ + allowPlugin: (pluginId: string) => void; + reset: () => void; + }; +} + +/** Tool-window view id of one plugin build system. */ +export function buildSystemViewId(pluginId: string, buildSystemId: string): string { + return `build-system:${pluginId}:${buildSystemId}`; +} + +export function parseBuildSystemViewId( + view: string, +): { pluginId: string; buildSystemId: string } | null { + if (!view.startsWith("build-system:")) { + return null; + } + const rest = view.slice("build-system:".length); + const separator = rest.indexOf(":"); + if (separator <= 0 || separator === rest.length - 1) { + return null; + } + return { + pluginId: rest.slice(0, separator), + buildSystemId: rest.slice(separator + 1), + }; +} + +function applyViewToState( + state: BuildSystemsState, + view: BuildView, + request: BuildRequestVersion, +): Partial { + // Late responses never win: an out-of-order sequence, a changed workspace + // root, or an older generation all mean the store already moved on. + if (request.sequence < state.lastAppliedSequence) { + return {}; + } + if (state.workspaceRoot && view.workspaceRoot !== state.workspaceRoot) { + return {}; + } + if (state.generation !== null && view.generation < state.generation) { + return {}; + } + + const wasRevokedAfterRequest = (pluginId: string | undefined) => + !!pluginId && + (state.revokedPlugins[pluginId] === true || + (state.pluginRevocationEpochs[pluginId] ?? 0) > request.revocationEpoch); + const grouped = new Map(); + for (const instance of view.instances) { + // A request that started before disable may still carry that plugin in + // its native snapshot. Drop only the revoked owner so unrelated plugin + // state in the same complete view remains legal and can still update. + if (wasRevokedAfterRequest(instance.pluginID)) { + continue; + } + const viewId = buildSystemViewId(instance.pluginID, instance.buildSystemID); + const existing = grouped.get(viewId); + if (existing) { + existing.instances.push(instance); + continue; + } + grouped.set(viewId, { + pluginId: instance.pluginID, + buildSystemId: instance.buildSystemID, + displayName: instance.displayName, + instances: [instance], + selectedRoot: null, + }); + } + // Preserve the user's instance selection when the instance is still there; + // otherwise fall back to the first instance deterministically. + const windows: Record = {}; + for (const [viewId, next] of grouped) { + const previous = state.windows[viewId]; + const stillSelected = + previous?.selectedRoot && + next.instances.some((instance) => instance.root === previous.selectedRoot); + next.selectedRoot = stillSelected ? previous!.selectedRoot : (next.instances[0]?.root ?? null); + windows[viewId] = next; + } + + return { + status: "ready", + generation: view.generation, + workspaceRoot: view.workspaceRoot, + error: null, + issues: view.issues.filter( + (issue) => !wasRevokedAfterRequest(issue.pluginID) && !wasRevokedAfterRequest(issue.by), + ), + windows, + lastAppliedSequence: request.sequence, + }; +} + +const createBuildSystemsStore = () => + create()((set, get) => ({ + status: "idle", + generation: null, + workspaceRoot: null, + error: null, + issues: [], + windows: {}, + requestSequence: 0, + lastAppliedSequence: 0, + revocationEpoch: 0, + pluginRevocationEpochs: {}, + revokedPlugins: {}, + actions: { + beginRequest: () => { + const sequence = get().requestSequence + 1; + set((state) => ({ requestSequence: sequence, status: "loading" })); + return { sequence, revocationEpoch: get().revocationEpoch }; + }, + applyView: (view, request) => set((state) => applyViewToState(state, view, request)), + fail: (message, request) => + set((state) => { + // A failure only lands when no newer response already applied; + // otherwise the late error would clobber fresher state. A request + // crossing any revoke cannot name a safe owner, so its failure is + // stale as well. + if ( + request.sequence < state.lastAppliedSequence || + request.revocationEpoch !== state.revocationEpoch + ) { + return state; + } + return { + status: "failed", + error: message, + lastAppliedSequence: request.sequence, + }; + }), + dropPlugin: (pluginId) => + set((state) => { + const revocationEpoch = state.revocationEpoch + 1; + const windows = Object.fromEntries( + Object.entries(state.windows).filter(([, target]) => target.pluginId !== pluginId), + ); + const issues = state.issues.filter((issue) => issue.pluginID !== pluginId); + return { + status: "ready", + error: null, + windows, + issues, + revocationEpoch, + pluginRevocationEpochs: { + ...state.pluginRevocationEpochs, + [pluginId]: revocationEpoch, + }, + revokedPlugins: { ...state.revokedPlugins, [pluginId]: true }, + }; + }), + allowPlugin: (pluginId) => + set((state) => { + if (!state.revokedPlugins[pluginId]) { + return state; + } + const revokedPlugins = { ...state.revokedPlugins }; + delete revokedPlugins[pluginId]; + return { revokedPlugins }; + }), + selectInstance: (viewId, root) => + set((state) => { + const target = state.windows[viewId]; + if (!target || !target.instances.some((instance) => instance.root === root)) { + return state; + } + return { + windows: { + ...state.windows, + [viewId]: { ...target, selectedRoot: root }, + }, + }; + }), + reset: () => + set({ + status: "idle", + generation: null, + workspaceRoot: null, + error: null, + issues: [], + windows: {}, + requestSequence: 0, + lastAppliedSequence: 0, + revocationEpoch: 0, + pluginRevocationEpochs: {}, + revokedPlugins: {}, + }), + }, + })); + +export const useBuildSystemsStore = createWorkspaceScopedStore( + BUILD_SYSTEMS_STORE_KEY, + createBuildSystemsStore, +); diff --git a/windows/tauri/src/features/build-systems/build-systems-types.ts b/windows/tauri/src/features/build-systems/build-systems-types.ts new file mode 100644 index 0000000000..181215d8df --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-systems-types.ts @@ -0,0 +1,95 @@ +/** + * Shapes returned by the Rust build registry (`plugin_host/build.rs`). The + * frontend only renders this state; every build semantic is computed by Core + * through the registry, so no ecosystem knowledge lives here. + */ + +export interface BuildOptionChoice { + value: string; + title?: string; + fromModel?: boolean; +} + +export interface BuildOptionDescription { + id: string; + displayName?: string; + kind: "boolean" | "singleSelect" | "multiSelect" | "text" | "file" | "directory"; + value: unknown; + choices: BuildOptionChoice[]; + allowCustom?: boolean; +} + +export interface BuildModelTarget { + name: string; + tags?: string[]; +} + +export interface BuildModelTask { + id: string; + name: string; + group?: string; + category?: string[]; +} + +export interface BuildModelProject { + name: string; + path: string; + properties?: Record; + targets?: BuildModelTarget[]; + tasks?: BuildModelTask[]; +} + +export interface BuildModelError { + code: string; + message: string; +} + +/** One detected build-system instance with its session state. */ +export interface BuildInstanceView { + pluginID: string; + buildSystemID: string; + displayName: string; + root: string; + markers: string[]; + modelSource: "static" | "command" | "hostIntegration" | "provider" | null; + model?: { projects?: BuildModelProject[]; truncated?: boolean } | null; + modelError?: BuildModelError | null; + stale: boolean; + syncDigest: string | null; + optionFingerprint: string | null; + options: BuildOptionDescription[]; +} + +export interface BuildIssue { + kind: "rejected" | "shadowed"; + pluginID?: string; + buildSystemID?: string; + by?: string; + index?: number; + error?: { code: string; message: string }; +} + +export interface BuildView { + generation: number; + workspaceRoot: string; + instances: BuildInstanceView[]; + issues: BuildIssue[]; +} + +/** Declared build system of one enabled plugin, for window registration. */ +export interface BuildCatalogEntry { + pluginId: string; + pluginDisplayName: string; + buildSystemId: string; + buildSystemDisplayName: string; + icon: string | null; +} + +/** Instances of one build system, as one tool window shows them. */ +export interface BuildWindowState { + pluginId: string; + buildSystemId: string; + displayName: string; + instances: BuildInstanceView[]; + selectedRoot: string | null; +} diff --git a/windows/tauri/src/features/build-systems/components/build-system-pane.test.tsx b/windows/tauri/src/features/build-systems/components/build-system-pane.test.tsx new file mode 100644 index 0000000000..d3b18dc819 --- /dev/null +++ b/windows/tauri/src/features/build-systems/components/build-system-pane.test.tsx @@ -0,0 +1,309 @@ +import { afterAll, afterEach, expect, spyOn, test } from "bun:test"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import * as events from "@tauri-apps/api/event"; +import * as native from "@/platform/tauri-core"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { buildSystemViewId, useBuildSystemsStore } from "../build-systems-store"; +import type { BuildOptionDescription, BuildView } from "../build-systems-types"; + +const restoreDom = installHappyDom(); +const { createRoot } = await import("react-dom/client"); +const { default: BuildSystemPane } = await import("./build-system-pane"); +afterAll(restoreDom); + +const PLUGIN = "dev.lithe.fixture.build-static-file"; +const WORKSPACE = "C:/work/build-pane-fixture"; +const VIEW_ID = buildSystemViewId(PLUGIN, "cargo"); +const cleanups: Array<() => void | Promise> = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +function view(options: BuildOptionDescription[] = []): BuildView { + return { + generation: 1, + workspaceRoot: WORKSPACE, + issues: [], + instances: [ + { + pluginID: PLUGIN, + buildSystemID: "cargo", + displayName: "Cargo", + root: ".", + markers: ["Cargo.toml"], + modelSource: "static", + model: { projects: [{ name: "fixture-app", path: "." }] }, + stale: false, + syncDigest: "baseline", + optionFingerprint: "options", + options, + }, + ], + }; +} + +function applyView(next: BuildView): void { + const actions = useBuildSystemsStore.getState().actions; + actions.applyView(next, actions.beginRequest()); +} + +async function mount(initial = view()) { + workspaceRuntimeRegistry.activateWorkspace( + { id: "build-pane-tests", name: "Fixture", path: WORKSPACE }, + "ready", + ); + useBuildSystemsStore.getState().actions.reset(); + applyView(initial); + const invokeSpy = spyOn(native, "invoke").mockImplementation( + async (command: string): Promise => { + if (command === "declarative_plugins_build_catalog") return [] as T; + if (command === "declarative_plugins_build_refresh") return view() as T; + if (command === "declarative_plugins_build_set_option") return initial as T; + throw new Error(`unexpected command ${command}`); + }, + ); + const listenSpy = spyOn(events, "listen").mockResolvedValue(() => {}); + cleanups.push(() => { + invokeSpy.mockRestore(); + listenSpy.mockRestore(); + }); + const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previousAct = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + const host = document.createElement("div"); + document.body.append(host); + const root: Root = createRoot(host); + cleanups.push(async () => { + try { + await act(async () => root.unmount()); + } finally { + host.remove(); + if (previousAct === undefined) delete environment.IS_REACT_ACT_ENVIRONMENT; + else environment.IS_REACT_ACT_ENVIRONMENT = previousAct; + } + }); + const render = () => + root.render( + + {}} /> + , + ); + await act(async () => render()); + const reload = () => + [...host.querySelectorAll("button")].find((button) => button.textContent === "Reload")!; + return { host, root, render, reload, invokeSpy }; +} + +function setInput(input: HTMLInputElement, value: string): void { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new window.InputEvent("input", { bubbles: true, inputType: "insertText" })); +} + +test("zero detected instances show an empty state with a working reload", async () => { + const { host, reload, invokeSpy } = await mount({ ...view(), instances: [] }); + expect(host.textContent).toContain("No cargo project detected in this workspace."); + + await act(async () => reload().click()); + + expect(host.textContent).toContain("fixture-app"); + expect(host.textContent).not.toContain("No cargo project detected"); + expect( + invokeSpy.mock.calls.filter(([command]) => command === "declarative_plugins_build_refresh"), + ).toHaveLength(1); +}); + +test("detection failure can be retried from the open pane", async () => { + const { host, reload } = await mount(); + await act(async () => { + const actions = useBuildSystemsStore.getState().actions; + actions.fail("fixture detection failure", actions.beginRequest()); + }); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("fixture detection failure"); + + await act(async () => reload().click()); + + expect(host.querySelector('[role="alert"]')).toBeNull(); + expect(useBuildSystemsStore.getState().status).toBe("ready"); +}); + +test("stale models keep a reload entry and become current after reloading", async () => { + const { host, reload } = await mount(); + await act(async () => { + const stale = view(); + stale.instances[0]!.stale = true; + applyView(stale); + }); + expect(host.textContent).toContain("The project model is out of date."); + + await act(async () => reload().click()); + + expect(host.textContent).not.toContain("The project model is out of date."); + expect(useBuildSystemsStore.getState().windows[VIEW_ID]?.instances[0]?.stale).toBe(false); +}); + +for (const outcome of ["failed", "stale"] as const) { + test(`reopening a ${outcome} pane retries once without a render loop`, async () => { + const { root, render, invokeSpy } = await mount(); + await act(async () => root.render(null)); + const actions = useBuildSystemsStore.getState().actions; + if (outcome === "failed") { + actions.fail("retry on reopen", actions.beginRequest()); + } else { + const stale = view(); + stale.instances[0]!.stale = true; + applyView(stale); + } + await act(async () => render()); + await act(async () => render()); + expect( + invokeSpy.mock.calls.filter(([command]) => command === "declarative_plugins_build_refresh"), + ).toHaveLength(1); + expect(useBuildSystemsStore.getState().status).toBe("ready"); + }); +} + +test("singleSelect submits a complete declared choice instead of intermediate text", async () => { + const { host, invokeSpy } = await mount( + view([ + { + id: "profile", + displayName: "Profile", + kind: "singleSelect", + value: "debug", + choices: [ + { value: "debug", title: "Debug" }, + { value: "release", title: "Release" }, + ], + allowCustom: false, + }, + ]), + ); + expect(host.querySelector('input[aria-label="Profile"]')).toBeNull(); + await act(async () => host.querySelector('[aria-label="Profile"]')!.click()); + const release = [...document.querySelectorAll('[role="option"]')].find((option) => + option.textContent?.includes("Release"), + ); + expect(release).toBeDefined(); + await act(async () => release!.click()); + const edits = invokeSpy.mock.calls.filter( + ([command]) => command === "declarative_plugins_build_set_option", + ); + expect(edits).toHaveLength(1); + expect(edits[0]![1]).toMatchObject({ optionId: "profile", value: "release" }); +}); + +test("allowCustom singleSelect commits a chosen custom value", async () => { + const { host, invokeSpy } = await mount( + view([ + { + id: "profile", + displayName: "Profile", + kind: "singleSelect", + value: "debug", + choices: [{ value: "debug", title: "Debug" }], + allowCustom: true, + }, + ]), + ); + await act(async () => host.querySelector('[aria-label="Profile"]')!.click()); + const search = document.querySelector('input[role="combobox"]')!; + expect(search).not.toBeNull(); + await act(async () => setInput(search, "custom-profile")); + expect( + invokeSpy.mock.calls.filter(([command]) => command === "declarative_plugins_build_set_option"), + ).toHaveLength(0); + const custom = [...document.querySelectorAll('[role="option"]')].find((option) => + option.textContent?.includes("custom-profile"), + ); + expect(custom).toBeDefined(); + await act(async () => custom!.click()); + const edits = invokeSpy.mock.calls.filter( + ([command]) => command === "declarative_plugins_build_set_option", + ); + expect(edits).toHaveLength(1); + expect(edits[0]![1]).toMatchObject({ optionId: "profile", value: "custom-profile" }); +}); + +test("a rejected draft can be submitted again without changing the text", async () => { + const { host, invokeSpy } = await mount( + view([ + { + id: "path", + displayName: "Path", + kind: "text", + value: "initial", + choices: [], + }, + ]), + ); + const input = host.querySelector('input[aria-label="Path"]')!; + await act(async () => input.focus()); + await act(async () => setInput(input, "retry-value")); + invokeSpy.mockRejectedValueOnce(new Error("temporary option failure")); + const submit = () => + input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await act(async () => { + submit(); + }); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("temporary option failure"); + expect(input.value).toBe("retry-value"); + await act(async () => { + submit(); + }); + const edits = invokeSpy.mock.calls.filter( + ([command]) => command === "declarative_plugins_build_set_option", + ); + expect(edits).toHaveLength(2); + expect(edits[1]![1]).toMatchObject({ optionId: "path", value: "retry-value" }); +}); + +for (const kind of ["text", "file", "directory"] as const) { + test(`${kind} edits retain a local draft and submit only on Enter or blur`, async () => { + const initial = view([ + { + id: "path", + displayName: "Path", + kind, + value: "initial", + choices: [], + }, + ]); + const { host, invokeSpy } = await mount(initial); + const input = host.querySelector('input[aria-label="Path"]')!; + await act(async () => input.focus()); + await act(async () => setInput(input, "C:/my folder")); + expect(input.value).toBe("C:/my folder"); + expect( + invokeSpy.mock.calls.filter( + ([command]) => command === "declarative_plugins_build_set_option", + ), + ).toHaveLength(0); + + // A watcher response with an effective value does not swallow a local + // edit while this input owns focus. + const external = view([{ ...initial.instances[0]!.options[0]!, value: "external" }]); + await act(async () => applyView(external)); + expect(input.value).toBe("C:/my folder"); + await act(async () => + input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })), + ); + await act(async () => input.blur()); + const edits = invokeSpy.mock.calls.filter( + ([command]) => command === "declarative_plugins_build_set_option", + ); + expect(edits).toHaveLength(1); + expect(edits[0]![1]).toMatchObject({ optionId: "path", value: "C:/my folder" }); + + await act(async () => input.focus()); + await act(async () => setInput(input, "")); + await act(async () => input.blur()); + const finalEdits = invokeSpy.mock.calls.filter( + ([command]) => command === "declarative_plugins_build_set_option", + ); + expect(finalEdits).toHaveLength(2); + expect(finalEdits[1]![1]).toMatchObject({ optionId: "path", value: "" }); + }); +} diff --git a/windows/tauri/src/features/build-systems/components/build-system-pane.tsx b/windows/tauri/src/features/build-systems/components/build-system-pane.tsx new file mode 100644 index 0000000000..ed3882cee7 --- /dev/null +++ b/windows/tauri/src/features/build-systems/components/build-system-pane.tsx @@ -0,0 +1,452 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; +import Input from "@/ui/input"; +import Select from "@/ui/select"; +import { Spinner } from "@/ui/spinner"; +import { parseBuildSystemViewId, useBuildSystemsStore } from "../build-systems-store"; +import { + acquireBuildSyncWatcher, + refreshBuildSystems, + setBuildOption, +} from "../build-systems-service"; +import type { BuildOptionDescription, BuildWindowState } from "../build-systems-types"; + +/** + * Native tool window for one declarative build system. This batch renders + * the read-only model (projects, targets, tasks), option controls resolved + * by Core, and staleness/empty states; task, test, and run actions arrive + * with the process-executing milestone and have no entries here yet. + */ + +export default function BuildSystemPane({ view, onClose }: { view: string; onClose: () => void }) { + const { t } = useTranslation(); + const workspaceId = useActiveWorkspaceId(); + const parsed = parseBuildSystemViewId(view); + const status = useBuildSystemsStore((state) => state.status); + const error = useBuildSystemsStore((state) => state.error); + const issues = useBuildSystemsStore((state) => state.issues); + const windowState = useBuildSystemsStore((state) => (parsed ? state.windows[view] : undefined)); + const selectInstance = useBuildSystemsStore((state) => state.actions.selectInstance); + + // Mount/reopen retries failed and stale models once. Workspace activation + // itself is owned by the bootstrap service, including already-ready stores. + useEffect(() => { + const state = useBuildSystemsStore.getStore(workspaceId).getState(); + if ( + state.status === "idle" || + state.status === "failed" || + (state.status === "ready" && + Object.values(state.windows).some((target) => + target.instances.some((instance) => instance.stale || instance.modelError), + )) + ) { + void refreshBuildSystems().catch(() => {}); + } + }, [workspaceId]); + + // The debounced sync watcher lives while any build window is mounted; the + // last unmount releases the shared listener. + useEffect(() => { + let release: (() => void) | null = null; + let cancelled = false; + void acquireBuildSyncWatcher().then((releaseWatcher) => { + if (cancelled) { + releaseWatcher(); + return; + } + release = releaseWatcher; + }); + return () => { + cancelled = true; + release?.(); + }; + }, []); + + const instance = useMemo( + () => + windowState?.instances.find((item) => item.root === windowState.selectedRoot) ?? + windowState?.instances[0] ?? + null, + [windowState], + ); + + if (!parsed) { + return null; + } + const displayName = windowState?.displayName ?? parsed.buildSystemId; + + return ( +
+
+

{displayName}

+ + +
+ + {issues.length > 0 && ( +
+ {issues.map((issue, index) => ( +

+ {issue.kind === "rejected" + ? t("buildSystems.manifestRejected", { + plugin: issue.pluginID ?? "", + message: issue.error?.message ?? "", + }) + : t("buildSystems.shadowed", { + plugin: issue.pluginID ?? "", + by: issue.by ?? "", + })} +

+ ))} +
+ )} + {error && ( +
+ {t("buildSystems.detectFailed", { message: error })} +
+ )} + + {status === "loading" && !windowState && ( +
+ + {t("buildSystems.loading")} +
+ )} + + {status === "ready" && !windowState?.instances.length && ( +
+

{t("buildSystems.emptyState", { name: displayName })}

+
+ )} + + {windowState && windowState.instances.length > 0 && ( +
+ {windowState.instances.length > 1 && ( +
+ + ({ + value: choice.value, + label: choice.title ?? choice.value, + }))} + searchable={option.allowCustom === true} + allowCustomValue={option.allowCustom === true} + onChange={commit} + disabled={disabled} + aria-label={title} + /> + +
+ ); + } + + return ( +
+ {title} + + +
+ ); +} + +/** Keeps partial edits local until a complete value is submitted to Core. */ +function DraftOptionInput({ + value, + title, + disabled, + onCommit, +}: { + value: string; + title: string; + disabled: boolean; + onCommit: (value: string) => Promise; +}) { + const [draft, setDraft] = useState(value); + const focused = useRef(false); + const submitted = useRef(value); + useEffect(() => { + if (!focused.current) { + setDraft(value); + submitted.current = value; + } + }, [value]); + const submit = () => { + if (draft === submitted.current) return; + const previous = submitted.current; + submitted.current = draft; + void onCommit(draft).then((accepted) => { + if (!accepted && submitted.current === draft) submitted.current = previous; + }); + }; + return ( + { + focused.current = true; + }} + onChange={(event) => setDraft(event.target.value)} + onBlur={() => { + focused.current = false; + submit(); + }} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter") { + event.preventDefault(); + submit(); + } else if (event.key === "Escape") { + event.preventDefault(); + setDraft(value); + submitted.current = value; + } + }} + /> + ); +} diff --git a/windows/tauri/src/features/editor/hooks/use-lsp-integration-declarative.test.tsx b/windows/tauri/src/features/editor/hooks/use-lsp-integration-declarative.test.tsx new file mode 100644 index 0000000000..5c01b2f8b4 --- /dev/null +++ b/windows/tauri/src/features/editor/hooks/use-lsp-integration-declarative.test.tsx @@ -0,0 +1,141 @@ +import { afterAll, expect, spyOn, test } from "bun:test"; +import { act } from "react"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import * as native from "@/platform/tauri-core"; +import * as deferredWork from "@/features/editor/lsp/deferred-lsp-work"; +import { LspClient } from "@/features/editor/lsp/lsp-client"; +import { setPluginActive } from "@/features/plugins/declarative-plugin-lifecycle"; + +const restoreDom = installHappyDom(); +afterAll(restoreDom); +const { createRoot } = await import("react-dom/client"); +const { useLspIntegration } = await import("./use-lsp-integration"); + +function mockNativeInvoke( + handler: (command: string, args?: Parameters[1]) => Promise, +) { + return spyOn(native, "invoke").mockImplementation( + async (command: string, args?: Parameters[1]): Promise => + (await handler(command, args)) as T, + ); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function Editor({ path }: { path: string }) { + useLspIntegration({ filePath: path }); + return null; +} + +test("a native-only language passes the editor admission gate and opens its LSP document", async () => { + const pluginId = "io.example.hook-activation"; + setPluginActive(pluginId, true); + const answer = deferred<{ pluginId: string; displayName: string; languageId: string } | null>(); + const entered = deferred(); + const scheduled = new Set<() => void>(); + const scheduling = spyOn(deferredWork, "deferUntilAfterNextPaint").mockImplementation( + (callback) => { + scheduled.add(callback); + return () => { + scheduled.delete(callback); + }; + }, + ); + const starts: string[] = []; + const opened: string[] = []; + const stopped: string[] = []; + const client = { + startForFile: async (path: string) => { + starts.push(path); + return { kind: "attached", attachmentId: "fixture-attachment" }; + }, + notifyDocumentOpen: async (path: string) => { + opened.push(path); + }, + notifyDocumentClose: async () => {}, + stopForFile: async (path: string) => { + stopped.push(path); + }, + } as unknown as LspClient; + const instance = spyOn(LspClient, "getInstance").mockReturnValue(client); + const invoke = mockNativeInvoke(async (command) => { + if (command === "declarative_plugins_language_owner") { + entered.resolve(); + return answer.promise; + } + throw new Error(`unexpected native command ${command}`); + }); + const host = document.createElement("div"); + const root = createRoot(host); + const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previousAct = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + try { + const path = "C:/fixture/main.hookactivation"; + await act(async () => root.render()); + await entered.promise; + expect(starts).toEqual([]); + await act(async () => + answer.resolve({ pluginId, displayName: "Fixture", languageId: "hookactivation" }), + ); + expect(scheduled.size).toBe(1); + await act(async () => { + for (const callback of scheduled) callback(); + scheduled.clear(); + }); + expect(starts).toEqual([path]); + expect(opened).toEqual([path]); + await act(async () => setPluginActive(pluginId, false)); + expect(stopped).toEqual([path]); + } finally { + answer.resolve(null); + await act(async () => root.unmount()); + environment.IS_REACT_ACT_ENVIRONMENT = previousAct; + setPluginActive(pluginId, false); + scheduled.clear(); + invoke.mockRestore(); + instance.mockRestore(); + scheduling.mockRestore(); + host.remove(); + } +}); + +test("unclaimed or virtual editor files never schedule a server start", async () => { + const scheduled: Array<() => void> = []; + const scheduling = spyOn(deferredWork, "deferUntilAfterNextPaint").mockImplementation( + (callback) => { + scheduled.push(callback); + return () => {}; + }, + ); + const entered = deferred(); + const invoke = mockNativeInvoke(async (command) => { + expect(command).toBe("declarative_plugins_language_owner"); + entered.resolve(); + return null; + }); + const client = spyOn(LspClient, "getInstance").mockReturnValue({} as LspClient); + const root = createRoot(document.createElement("div")); + const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previousAct = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + try { + await act(async () => root.render()); + await entered.promise; + await act(async () => root.render()); + expect(invoke).toHaveBeenCalledTimes(1); + expect(scheduled).toEqual([]); + } finally { + await act(async () => root.unmount()); + environment.IS_REACT_ACT_ENVIRONMENT = previousAct; + invoke.mockRestore(); + client.mockRestore(); + scheduling.mockRestore(); + } +}); diff --git a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts index 6fe5b10e99..b1d8564b7d 100644 --- a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts +++ b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts @@ -1,7 +1,14 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import { useExtensionStore } from "@/extensions/registry/extension-store"; import { deferUntilAfterNextPaint } from "@/features/editor/lsp/deferred-lsp-work"; -import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; +import { + isEditorLspSupported, + resolveEditorLspSupport, +} from "@/features/editor/lsp/built-in-language-support"; +import { + declarativeLanguageSupportRevision, + subscribeDeclarativeLanguageSupport, +} from "@/features/editor/lsp/declarative-language-support"; import { LspClient } from "@/features/editor/lsp/lsp-client"; import { hasLspDocumentChanges, @@ -73,13 +80,27 @@ export const useLspIntegration = ({ const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); const installedExtensions = useExtensionStore.use.installedExtensions(); const activeFilePath = enabled ? filePath : undefined; + const declarativeRevision = useSyncExternalStore( + subscribeDeclarativeLanguageSupport, + declarativeLanguageSupportRevision, + declarativeLanguageSupportRevision, + ); const isLspSupported = useMemo( () => isEditorLspSupported(activeFilePath), - [activeFilePath, installedExtensions], + [activeFilePath, installedExtensions, declarativeRevision], ); const lastSyncedRevisionRef = useRef(contentRevision); const documentOwnersRef = useRef>(new Map()); + useEffect(() => { + if (!activeFilePath || isLspSupported) return; + // Worker and built-in admission stay synchronous. Only unclaimed local + // files ask the native catalog, which performs no launch or tool probe. + void resolveEditorLspSupport(activeFilePath).catch((error: unknown) => { + logger.warn("LspIntegration", "Could not resolve declarative language ownership", error); + }); + }, [activeFilePath, installedExtensions, isLspSupported, declarativeRevision]); + useEffect(() => { if (!enabled || !filePath || !isLspSupported) return; @@ -114,7 +135,9 @@ export const useLspIntegration = ({ const isStillOpen = Boolean( getSourceEditorBufferByPath(useBufferStore.getState().buffers, filePath), ); - if (isStillOpen) return; + // An open buffer can retain an attachment across view changes, but a + // revoked plugin must release it even while its document stays open. + if (isStillOpen && isEditorLspSupported(filePath)) return; if (owner.state.phase === "stopping" || owner.state.phase === "stopped") return; const previousState = owner.state; diff --git a/windows/tauri/src/features/editor/lsp/built-in-language-support.ts b/windows/tauri/src/features/editor/lsp/built-in-language-support.ts index d8505f94ab..57011cbeb7 100644 --- a/windows/tauri/src/features/editor/lsp/built-in-language-support.ts +++ b/windows/tauri/src/features/editor/lsp/built-in-language-support.ts @@ -1,15 +1,14 @@ import { extensionRegistry } from "@/extensions/registry/extension-registry"; import { getBaseName, normalizePath } from "@/utils/path-helpers"; +import { + cachedDeclarativeLanguageOwner, + probeDeclarativeLanguageOwner, +} from "./declarative-language-support"; export const JAVA_LANGUAGE_ID = "java"; export const JAVA_PROVIDER_ID = "java"; -const VIRTUAL_PATH_PREFIXES = [ - "remote://", - "wsl://", - "diff://", - "agent-conversation://", -]; +const VIRTUAL_PATH_PREFIXES = ["remote://", "wsl://", "diff://", "agent-conversation://"]; export function isJavaSourcePath(filePath: string | undefined): boolean { if (!filePath || isVirtualEditorPath(filePath)) return false; @@ -22,13 +21,28 @@ export function isBuiltInLspPath(filePath: string | undefined): boolean { export function isEditorLspSupported(filePath: string | undefined): boolean { if (!filePath || isVirtualEditorPath(filePath)) return false; - return isBuiltInLspPath(filePath) || extensionRegistry.isLspSupported(filePath); + return ( + isBuiltInLspPath(filePath) || + extensionRegistry.isLspSupported(filePath) || + Boolean(cachedDeclarativeLanguageOwner(filePath)) + ); +} + +/** Resolve only ownership; native launch validation stays in the LSP resolver. */ +export async function resolveEditorLspSupport(filePath: string | undefined): Promise { + if (!filePath || isVirtualEditorPath(filePath)) return false; + if (isEditorLspSupported(filePath)) return true; + return Boolean(await probeDeclarativeLanguageOwner(filePath)); } export function languageIdForEditorFile(filePath: string | undefined): string | undefined { if (!filePath || isVirtualEditorPath(filePath)) return undefined; if (isJavaSourcePath(filePath)) return JAVA_LANGUAGE_ID; - return extensionRegistry.getLanguageId(filePath) || undefined; + return ( + extensionRegistry.getLanguageId(filePath) || + cachedDeclarativeLanguageOwner(filePath)?.languageId || + undefined + ); } function isVirtualEditorPath(filePath: string): boolean { diff --git a/windows/tauri/src/features/editor/lsp/declarative-language-support.test.ts b/windows/tauri/src/features/editor/lsp/declarative-language-support.test.ts new file mode 100644 index 0000000000..3496784f19 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/declarative-language-support.test.ts @@ -0,0 +1,193 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as native from "@/platform/tauri-core"; +import { extensionRegistry } from "@/extensions/registry/extension-registry"; +import { setPluginActive } from "@/features/plugins/declarative-plugin-lifecycle"; +import { + isEditorLspSupported, + languageIdForEditorFile, + resolveEditorLspSupport, +} from "./built-in-language-support"; +import { subscribeDeclarativeLanguageSupport } from "./declarative-language-support"; +import { resolveEditorLspLaunch } from "./resolve-editor-lsp-launch"; + +const pluginId = "io.example.editor-admission"; +const owner = { pluginId, displayName: "Fixture", languageId: "admission" }; +afterEach(() => { + setPluginActive(pluginId, false); +}); + +function mockNativeInvoke( + handler: (command: string, args?: Parameters[1]) => Promise, +) { + return spyOn(native, "invoke").mockImplementation( + async (command: string, args?: Parameters[1]): Promise => + (await handler(command, args)) as T, + ); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test("native-only ownership admits the file and reaches the declarative launch resolver", async () => { + setPluginActive(pluginId, true); + const path = "C:/fixture/main.admission"; + const calls: string[] = []; + const spy = mockNativeInvoke(async (command) => { + calls.push(command); + if (command === "declarative_plugins_language_owner") return owner; + if (command === "declarative_plugins_language_launch") + return { + pluginId, + languageId: "admission", + serverPath: "C:/fixture/server.exe", + serverArgs: ["--stdio"], + initializationOptions: null, + workspaceConfiguration: null, + environment: {}, + }; + throw new Error(`unexpected command ${command}`); + }); + try { + expect(isEditorLspSupported(path)).toBe(false); + expect(await resolveEditorLspSupport(path)).toBe(true); + expect(isEditorLspSupported(path)).toBe(true); + expect(languageIdForEditorFile(path)).toBe("admission"); + const launch = await resolveEditorLspLaunch(path, { + workspaceId: "fixture", + root: "C:/fixture", + }); + expect(launch?.pluginId).toBe(pluginId); + expect(launch?.serverPath).toBe("C:/fixture/server.exe"); + expect(calls).toEqual([ + "declarative_plugins_language_owner", + "declarative_plugins_language_launch", + ]); + } finally { + spy.mockRestore(); + } +}); + +test("unsupported and virtual documents never reach a launch command", async () => { + const paths: string[] = []; + const spy = mockNativeInvoke(async (command, args) => { + expect(command).toBe("declarative_plugins_language_owner"); + paths.push((args as { filePath: string }).filePath); + return null; + }); + try { + for (const path of [ + undefined, + "remote://host/main.admission", + "wsl://host/main.admission", + "diff://file/main.admission", + "agent-conversation://main.admission", + ]) { + expect(await resolveEditorLspSupport(path)).toBe(false); + } + expect(await resolveEditorLspSupport("C:/fixture/no-owner.admission")).toBe(false); + expect(isEditorLspSupported("C:/fixture/no-owner.admission")).toBe(false); + expect(paths).toEqual(["C:/fixture/no-owner.admission"]); + } finally { + spy.mockRestore(); + } +}); + +test("Java and Worker ownership keep their synchronous priority without native probing", async () => { + const supported = spyOn(extensionRegistry, "isLspSupported").mockReturnValue(true); + const language = spyOn(extensionRegistry, "getLanguageId").mockReturnValue("worker-fixture"); + const spy = mockNativeInvoke(async () => { + throw new Error("a known language must not probe declarative ownership"); + }); + try { + expect(await resolveEditorLspSupport("C:/fixture/Main.java")).toBe(true); + expect(languageIdForEditorFile("C:/fixture/Main.java")).toBe("java"); + expect(await resolveEditorLspSupport("C:/fixture/Main.workerfixture")).toBe(true); + expect(languageIdForEditorFile("C:/fixture/Main.workerfixture")).toBe("worker-fixture"); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + supported.mockRestore(); + language.mockRestore(); + } +}); + +test("a late ownership answer cannot admit a plugin disabled during its request", async () => { + setPluginActive(pluginId, true); + const answer = deferred(); + const entered = deferred(); + const path = "C:/fixture/late.admission"; + const spy = mockNativeInvoke(async () => { + entered.resolve(); + return answer.promise; + }); + const pending = resolveEditorLspSupport(path); + try { + await entered.promise; + setPluginActive(pluginId, false); + answer.resolve(owner); + expect(await pending).toBe(false); + expect(isEditorLspSupported(path)).toBe(false); + expect(languageIdForEditorFile(path)).toBeUndefined(); + } finally { + answer.resolve(owner); + await pending; + spy.mockRestore(); + } +}); + +test("revocation invalidates cached hints even while no editor subscribes", async () => { + setPluginActive(pluginId, true); + const path = "C:/fixture/cached.admission"; + const spy = spyOn(native, "invoke").mockResolvedValue(owner); + try { + expect(await resolveEditorLspSupport(path)).toBe(true); + setPluginActive(pluginId, false); + expect(isEditorLspSupported(path)).toBe(false); + setPluginActive(pluginId, true); + expect(isEditorLspSupported(path)).toBe(false); + expect(await resolveEditorLspSupport(path)).toBe(true); + expect(spy).toHaveBeenCalledTimes(2); + } finally { + spy.mockRestore(); + } +}); + +for (const continuousSubscription of [true, false]) { + test(`reenabling rejects revoked lookups with ${continuousSubscription ? "an active editor" : "a reopened editor"}`, async () => { + setPluginActive(pluginId, true); + const answer = deferred(); + const entered = deferred(); + let unsubscribe = subscribeDeclarativeLanguageSupport(() => {}); + const path = "C:/fixture/reenable.admission"; + let calls = 0; + const spy = mockNativeInvoke(async () => { + if (++calls === 1) { + entered.resolve(); + return answer.promise; + } + return owner; + }); + const old = resolveEditorLspSupport(path); + try { + await entered.promise; + if (!continuousSubscription) unsubscribe(); + setPluginActive(pluginId, false); + setPluginActive(pluginId, true); + if (!continuousSubscription) unsubscribe = subscribeDeclarativeLanguageSupport(() => {}); + expect(await resolveEditorLspSupport(path)).toBe(true); + answer.resolve(owner); + expect(await old).toBe(false); + expect(isEditorLspSupported(path)).toBe(true); + } finally { + answer.resolve(owner); + await old; + unsubscribe(); + spy.mockRestore(); + } +}); +} diff --git a/windows/tauri/src/features/editor/lsp/declarative-language-support.ts b/windows/tauri/src/features/editor/lsp/declarative-language-support.ts new file mode 100644 index 0000000000..35ba49a862 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/declarative-language-support.ts @@ -0,0 +1,98 @@ +import { + pluginPlanGuard, + subscribePluginActivity, +} from "@/features/plugins/declarative-plugin-lifecycle"; + +interface DeclarativeLanguageOwner { + pluginId: string; + languageId: string; +} + +interface CachedOwner { + owner: DeclarativeLanguageOwner; + assertCurrent: (pluginId: string) => void; +} + +// These are native ownership hints for editor admission, not a second +// manifest matcher. The actual launch still rechecks the trusted live package. +const owners = new Map(); +const pending = new Map>(); +const listeners = new Set<() => void>(); +let revision = 0; +let unsubscribeActivity: (() => void) | null = null; + +function publishChange(): void { + revision += 1; + for (const listener of listeners) listener(); +} + +export function declarativeLanguageSupportRevision(): number { + return revision; +} + +export function subscribeDeclarativeLanguageSupport(listener: () => void): () => void { + listeners.add(listener); + if (!unsubscribeActivity) { + // Revocation may have happened while every editor was unmounted. Do + // not reuse an old lookup whose rejected answer cannot wake this editor. + pending.clear(); + unsubscribeActivity = subscribePluginActivity((pluginId) => { + for (const [path, cached] of owners) { + if (cached.owner.pluginId === pluginId) owners.delete(path); + } + // Pending answers do not carry an owner yet. Let the active editor + // issue a fresh lookup; the old answer remains protected by its guard. + pending.clear(); + publishChange(); + }); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + unsubscribeActivity?.(); + unsubscribeActivity = null; + } + }; +} + +export function cachedDeclarativeLanguageOwner(filePath: string): DeclarativeLanguageOwner | null { + const cached = owners.get(filePath); + if (!cached) return null; + try { + cached.assertCurrent(cached.owner.pluginId); + return cached.owner; + } catch { + // A mutation while no editor subscribed must also invalidate the hint. + owners.delete(filePath); + return null; + } +} + +/** Query accepted/enabled ownership without preparing or starting a process. */ +export function probeDeclarativeLanguageOwner( + filePath: string, +): Promise { + const cached = cachedDeclarativeLanguageOwner(filePath); + if (cached) return Promise.resolve(cached); + const existing = pending.get(filePath); + if (existing) return existing; + const assertCurrent = pluginPlanGuard(); + const task = (async () => { + const { invokeDeclarativePluginOwner } = + await import("@/features/plugins/declarative-plugin-launch"); + const owner = await invokeDeclarativePluginOwner(filePath); + if (!owner) return null; + try { + assertCurrent(owner.pluginId); + } catch { + return null; + } + owners.set(filePath, { owner, assertCurrent }); + publishChange(); + return owner; + })().finally(() => { + if (pending.get(filePath) === task) pending.delete(filePath); + }); + pending.set(filePath, task); + return task; +} diff --git a/windows/tauri/src/features/layout/components/main-layout.tsx b/windows/tauri/src/features/layout/components/main-layout.tsx index b318537de1..82ada5fc22 100644 --- a/windows/tauri/src/features/layout/components/main-layout.tsx +++ b/windows/tauri/src/features/layout/components/main-layout.tsx @@ -6,6 +6,7 @@ import { useFileSystemFolderDrop } from "@/features/file-system/hooks/use-file-s import { openDroppedWorkspacePaths } from "@/features/file-system/utils/open-dropped-workspace-paths"; import { GitStatusRefreshHost } from "@/features/git/runtime/git-status-refresh-host"; import { closeMavenToolWindow } from "@/features/maven/actions/maven-tool-window-actions"; +import { applyRightToolWindowIntent } from "@/features/layout/actions/right-tool-window-actions"; import { closeAgentToolWindow } from "@/features/agent/actions/agent-tool-window-actions"; import { useMavenStore } from "@/features/maven/stores/maven.store"; import { useMavenResolutionNotifications } from "@/features/maven/hooks/use-maven-resolution-problems"; @@ -71,6 +72,9 @@ const TerminalHost = lazy(() => ); const BottomPane = lazy(() => import("./bottom-pane/bottom-pane")); const MavenPane = lazy(() => import("@/features/maven/components/maven-pane")); +const BuildSystemPane = lazy( + () => import("@/features/build-systems/components/build-system-pane"), +); const AgentPane = lazy(() => import("@/features/agent/components/agent-panel").then((module) => ({ default: module.AgentPanel, @@ -100,7 +104,11 @@ export function MainLayout() { const isMavenVisible = isRightSidebarVisible && isMavenSelected; const isAgentSelected = activeRightSidebarView === "agent"; const isAgentVisible = isRightSidebarVisible && isAgentSelected; - const isRightToolWindowVisible = isNotificationsVisible || isMavenVisible || isAgentVisible; + const isBuildSystemSelected = + activeRightSidebarView?.startsWith("build-system:") ?? false; + const isBuildSystemVisible = isRightSidebarVisible && isBuildSystemSelected; + const isRightToolWindowVisible = + isNotificationsVisible || isMavenVisible || isAgentVisible || isBuildSystemVisible; const vimRelativeLineNumbers = useSettingsStore((state) => state.settings.vimRelativeLineNumbers); const relativeLineNumbers = useVimStore.use.relativeLineNumbers(); const { setRelativeLineNumbers } = useVimStore.use.actions(); @@ -316,6 +324,16 @@ export function MainLayout() { ) : null} + {isBuildSystemSelected ? ( + + + applyRightToolWindowIntent(activeRightSidebarView, "close") + } + /> + + ) : null} } panel={ diff --git a/windows/tauri/src/features/layout/components/plugin-activity-rail.tsx b/windows/tauri/src/features/layout/components/plugin-activity-rail.tsx index 08c97e1c6a..54b59655e7 100644 --- a/windows/tauri/src/features/layout/components/plugin-activity-rail.tsx +++ b/windows/tauri/src/features/layout/components/plugin-activity-rail.tsx @@ -1,3 +1,7 @@ +import { + useBuildSystemsCatalogStore, +} from "@/features/build-systems/build-systems-service"; +import { toggleBuildSystemWindow } from "@/features/build-systems/actions/build-system-tool-window-actions"; import { useDeclarativePluginUI } from "@/features/plugins/declarative-plugin-ui"; import { openDeclarativeWebWindow } from "@/features/plugins/declarative-plugin-launch"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; @@ -12,6 +16,8 @@ import { MavenIcon } from "@/features/maven/components/maven-icon"; import { useMavenStore } from "@/features/maven/stores/maven.store"; import { NotificationsTrigger } from "@/features/notifications/components/notifications-trigger"; import { useUIState } from "@/features/window/stores/ui-state.store"; +import { buildSystemViewId } from "@/features/build-systems/build-systems-store"; +import type { BuildCatalogEntry } from "@/features/build-systems/build-systems-types"; import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; import { PuzzlePieceIcon, TerminalWindowIcon } from "@/ui/icons"; @@ -53,6 +59,7 @@ export function PluginActivityRail() { const isMavenAvailable = useMavenStore( (state) => Boolean(state.project) || state.projectStatus === "failed", ); + const buildSystemEntries = useBuildSystemsCatalogStore((state) => state.entries); const isMavenActive = useUIState( (state) => state.isRightSidebarVisible && state.activeRightSidebarView === "maven", ); @@ -104,6 +111,9 @@ export function PluginActivityRail() { ) : null} + {buildSystemEntries.map((entry) => ( + + ))} {terminalDock?.activitySide === "right" && terminalEnabled && !terminalHidden && @@ -132,3 +142,31 @@ export function PluginActivityRail() { ); } + +function BuildSystemRailButton({ entry }: { entry: BuildCatalogEntry }) { + const { t } = useTranslation(); + const viewId = buildSystemViewId(entry.pluginId, entry.buildSystemId); + const isActive = useUIState( + (state) => state.isRightSidebarVisible && state.activeRightSidebarView === viewId, + ); + const label = + entry.buildSystemDisplayName || + t("buildSystems.railLabel", { name: entry.pluginDisplayName }); + + return ( + + ); +} diff --git a/windows/tauri/src/features/plugins/declarative-marketplace-sources-section.tsx b/windows/tauri/src/features/plugins/declarative-marketplace-sources-section.tsx new file mode 100644 index 0000000000..e9230d301a --- /dev/null +++ b/windows/tauri/src/features/plugins/declarative-marketplace-sources-section.tsx @@ -0,0 +1,300 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import Switch from "@/ui/switch"; +import { + addDeclarativeMarketplaceSource, + listDeclarativeMarketplaceSources, + removeDeclarativeMarketplaceSource, + setDeclarativeMarketplaceSourceEnabled, + type DeclarativeMarketplaceSource, + type DeclarativeMarketplaceSourcesState, +} from "./declarative-marketplace-sources"; + +/** + * Source management for the declarative marketplace. This slice covers the + * source list, add, enable/disable, remove, and its persistence only — no + * index refresh, download, or installation exists yet, and the UI must not + * suggest otherwise. + * + * Async discipline: + * - Mutations are serialized by a synchronous in-flight flag checked at + * every entry (keyboard and pointer paths share it), so double-Enter or + * Enter+click can never reach the host twice. + * - Reads and mutations own separate lifetimes: a read that started while a + * mutation was in flight is discarded no matter which resolves first, so + * a language switch cannot clobber a pending mutation, and the mutation's + * `finally` clears the busy flag unconditionally — a later read can never + * strand it. + * - No response touches an unmounted component, and operations stay + * disabled until the first load resolves. + */ +export function DeclarativeMarketplaceSources() { + const { t, language } = useTranslation(); + const [state, setState] = useState(null); + const [urlInput, setUrlInput] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<{ + kind: "load" | "mutation"; + message: string; + } | null>(null); + const requestRef = useRef(0); + const lastMutationStartRef = useRef(0); + const inFlightRef = useRef(false); + const mountedRef = useRef(true); + // The flag is set on every mount (StrictMode remounts included) and only + // cleared when the component really unmounts. + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + // Translations resolve at call time so `load` stays a stable read of the + // list; the localized strings it produces are always the current ones. + const tRef = useRef(t); + tRef.current = t; + + const applyIfMounted = useCallback((update: () => void) => { + if (!mountedRef.current) return; + update(); + }, []); + + // A read only owns the list when no mutation was in flight (or started + // earlier) when the read began; otherwise the mutation's authoritative + // list wins and the read is discarded whatever the resolve order is. + const readOwnsState = useCallback((request: number) => { + return !inFlightRef.current && request > lastMutationStartRef.current; + }, []); + + const load = useCallback(async () => { + const request = ++requestRef.current; + const ownsState = readOwnsState(request); + try { + const next = await listDeclarativeMarketplaceSources(); + if (!ownsState || request !== requestRef.current) return; + applyIfMounted(() => { + setState(next); + // A successful retry only resolves a previous read failure. A + // language-triggered read must not erase a newer mutation failure + // that still needs the user's attention. + setError((current) => (current?.kind === "load" ? null : current)); + if (next.problem) { + // The localized notice renders; the raw host diagnosis stays in + // the log where a report can include it. + console.warn("declarative marketplace sources:", next.problem); + } + }); + } catch (reason) { + console.warn("declarative marketplace sources load failed:", reason); + if (!ownsState || request !== requestRef.current) return; + applyIfMounted(() => + setError({ + kind: "load", + message: tRef.current("settings.declarativePlugins.sources.errorLoad"), + }), + ); + } + // A language switch re-reads the list on purpose; the t reference is + // resolved through tRef so the callback identity only follows the + // language. + }, [applyIfMounted, language, readOwnsState]); + + useEffect(() => { + void load(); + }, [load]); + + // Host rejections carry stable plugin.* codes; the user sees localized + // text, and the raw reason only reaches the log. + const describeError = useCallback( + (reason: unknown) => { + const message = reason instanceof Error ? reason.message : String(reason); + console.warn("declarative marketplace sources operation failed:", reason); + if (message.includes("plugin.marketplace.sourceAlreadyAdded")) { + return t("settings.declarativePlugins.sources.errorAlreadyAdded"); + } + if (message.includes("plugin.marketplace.officialSourceCannotBeRemoved")) { + return t("settings.declarativePlugins.sources.errorOfficialCannotBeRemoved"); + } + if (message.includes("plugin.marketplace.sourceNotFound")) { + return t("settings.declarativePlugins.sources.errorNotFound"); + } + if ( + message.includes("plugin.marketplace.sources.emptyInput") || + message.includes("plugin.source.invalid") + ) { + return t("settings.declarativePlugins.sources.errorInvalidInput"); + } + return t("settings.declarativePlugins.sources.errorGeneric"); + }, + [t], + ); + + // The one serialized mutation path. The synchronous in-flight gate makes + // re-entry from keyboard and pointer alike a no-op, and the busy flag is + // always cleared in `finally` — never subject to request generations — so + // a language switch during an operation can never strand it. + const runSourceAction = useCallback( + ( + action: () => Promise, + onSuccess: (next: DeclarativeMarketplaceSource[]) => void, + ) => { + if (inFlightRef.current || state === null) return; + inFlightRef.current = true; + const request = ++requestRef.current; + lastMutationStartRef.current = request; + setBusy(true); + setError(null); + void action() + .then((next) => { + applyIfMounted(() => { + // A successful rewrite replaced the store, so any earlier + // corruption notice no longer describes the current file. + setState({ sources: next, problem: null }); + onSuccess(next); + }); + }) + .catch((reason: unknown) => { + const described = describeError(reason); + applyIfMounted(() => setError({ kind: "mutation", message: described })); + }) + .finally(() => { + inFlightRef.current = false; + applyIfMounted(() => setBusy(false)); + }); + }, + [applyIfMounted, describeError, state], + ); + + const addSource = () => { + const url = urlInput.trim(); + if (!url) return; + runSourceAction( + () => addDeclarativeMarketplaceSource(url), + () => setUrlInput((current) => current.trim() === url ? "" : current), + ); + }; + + const ready = state !== null && !busy; + + return ( +
+
+

+ {t("settings.declarativePlugins.sources.title")} +

+

+ {t("settings.declarativePlugins.sources.description")} +

+
+
+ setUrlInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") addSource(); + }} + /> + +
+ {error && ( +

+ {error.message} + {state === null && error.kind === "load" && ( + <> + {" "} + + + )} +

+ )} + {state?.problem && ( +

+ {t("settings.declarativePlugins.sources.corruptNotice")} +

+ )} +
    + {(state?.sources ?? []).map((source) => ( + + runSourceAction( + () => setDeclarativeMarketplaceSourceEnabled(source.id, enabled), + () => {}, + ) + } + onRemove={() => + runSourceAction( + () => removeDeclarativeMarketplaceSource(source.id), + () => {}, + ) + } + /> + ))} + {state && !state.sources.some((source) => source.kind === "thirdParty") && ( +
  • + {t("settings.declarativePlugins.sources.empty")} +
  • + )} +
+
+ ); +} + +function SourceRow({ + source, + busy, + onToggle, + onRemove, +}: { + source: DeclarativeMarketplaceSource; + busy: boolean; + onToggle: (enabled: boolean) => void; + onRemove: () => void; +}) { + const { t } = useTranslation(); + return ( +
  • +
    +

    + {source.name} + + {" "} + · {t(`settings.declarativePlugins.sources.${source.kind}`)} + +

    +

    {source.indexURL}

    +
    + + {source.deletable && ( + + )} +
  • + ); +} diff --git a/windows/tauri/src/features/plugins/declarative-marketplace-sources.test.ts b/windows/tauri/src/features/plugins/declarative-marketplace-sources.test.ts new file mode 100644 index 0000000000..74f1f36291 --- /dev/null +++ b/windows/tauri/src/features/plugins/declarative-marketplace-sources.test.ts @@ -0,0 +1,67 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as native from "@/platform/tauri-core"; +import { + addDeclarativeMarketplaceSource, + listDeclarativeMarketplaceSources, + removeDeclarativeMarketplaceSource, + setDeclarativeMarketplaceSourceEnabled, + type DeclarativeMarketplaceSource, +} from "./declarative-marketplace-sources"; + +const officialSource: DeclarativeMarketplaceSource = { + id: "lithe-official", + name: "Lithe", + input: "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json", + indexURL: "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json", + repository: "https://github.com/Lithe-IDEA/Lithe-IDEA", + isEnabled: true, + kind: "official", + isOfficial: true, + deletable: false, +}; + +// The wrappers are thin: every call must hit exactly one registered host +// command with snake_case payload keys the Rust commands read, pass the +// resolved value through, and surface host rejections without wrapping. +let invokeSpy: ReturnType | undefined; +afterEach(() => { + invokeSpy?.mockRestore(); + invokeSpy = undefined; +}); + +test("list returns the host state including the corruption diagnosis", async () => { + invokeSpy = spyOn(native, "invoke").mockResolvedValue({ + sources: [officialSource], + problem: "plugin.marketplace.sources.corrupt: boom", + }); + const state = await listDeclarativeMarketplaceSources(); + expect(invokeSpy).toHaveBeenCalledTimes(1); + expect(invokeSpy.mock.calls[0][0]).toBe("declarative_marketplace_sources_state"); + expect(state.problem).toContain("plugin.marketplace.sources.corrupt"); + expect(state.sources[0].deletable).toBe(false); +}); + +test("add forwards the raw input and passes host rejections through", async () => { + invokeSpy = spyOn(native, "invoke").mockRejectedValue( + new Error("plugin.marketplace.sourceAlreadyAdded: example/lithe-plugins"), + ); + await expect(addDeclarativeMarketplaceSource("https://github.com/example/lithe-plugins")).rejects.toThrow( + "plugin.marketplace.sourceAlreadyAdded", + ); + expect(invokeSpy.mock.calls[0][0]).toBe("declarative_marketplace_sources_add"); + expect(invokeSpy.mock.calls[0][1]).toEqual({ url: "https://github.com/example/lithe-plugins" }); +}); + +test("enable toggle carries the source id and the requested state", async () => { + invokeSpy = spyOn(native, "invoke").mockResolvedValue([]); + await setDeclarativeMarketplaceSourceEnabled("source-abc", false); + expect(invokeSpy.mock.calls[0][0]).toBe("declarative_marketplace_sources_set_enabled"); + expect(invokeSpy.mock.calls[0][1]).toEqual({ sourceId: "source-abc", enabled: false }); +}); + +test("remove targets exactly one deletable source id", async () => { + invokeSpy = spyOn(native, "invoke").mockResolvedValue([]); + await removeDeclarativeMarketplaceSource("source-abc"); + expect(invokeSpy.mock.calls[0][0]).toBe("declarative_marketplace_sources_remove"); + expect(invokeSpy.mock.calls[0][1]).toEqual({ sourceId: "source-abc" }); +}); diff --git a/windows/tauri/src/features/plugins/declarative-marketplace-sources.test.tsx b/windows/tauri/src/features/plugins/declarative-marketplace-sources.test.tsx new file mode 100644 index 0000000000..f76503c9b9 --- /dev/null +++ b/windows/tauri/src/features/plugins/declarative-marketplace-sources.test.tsx @@ -0,0 +1,645 @@ +import { afterAll, afterEach, expect, spyOn, test } from "bun:test"; +import { act, useState } from "react"; +import type { Root } from "react-dom/client"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import type { DisplayLanguage } from "@/i18n/locale"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import * as native from "@/platform/tauri-core"; +import type { + DeclarativeMarketplaceSource, + DeclarativeMarketplaceSourcesState, +} from "./declarative-marketplace-sources"; + +const restoreDom = installHappyDom(); +afterAll(restoreDom); +// Load browser-dependent modules after installing DOM globals, exactly like +// the settings-path-input test: react-dom and the component observe +// happy-dom, while react and LocaleProvider stay static. +const { createRoot } = await import("react-dom/client"); +const { StrictMode } = await import("react"); +const { DeclarativeMarketplaceSources } = await import("./declarative-marketplace-sources-section"); + +const officialSource: DeclarativeMarketplaceSource = { + id: "lithe-official", + name: "Lithe", + input: + "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json", + indexURL: + "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json", + repository: "https://github.com/Lithe-IDEA/Lithe-IDEA", + isEnabled: true, + kind: "official", + isOfficial: true, + deletable: false, +}; + +const communitySource: DeclarativeMarketplaceSource = { + id: "source-39170ddcae1870bf", + name: "Lithe Store", + input: "https://github.com/Lithe-IDEA/Lithe-store", + indexURL: "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-store/HEAD/lithe-plugin-index.json", + repository: "https://github.com/Lithe-IDEA/Lithe-store", + isEnabled: true, + kind: "thirdParty", + isOfficial: false, + deletable: true, +}; + +function setInputValue(input: HTMLInputElement, value: string) { + // React 19 only sees the change through a real InputEvent; a plain Event + // updates the DOM value without reaching the controlled state. + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(input, value); + input.dispatchEvent(new window.InputEvent("input", { bubbles: true, inputType: "insertText" })); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const state = ( + sources: DeclarativeMarketplaceSource[], + problem: string | null = null, +): DeclarativeMarketplaceSourcesState => ({ + sources, + problem, +}); + +const consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); +afterEach(() => consoleErrorSpy.mockClear()); +afterAll(() => consoleErrorSpy.mockRestore()); + +async function mountWith( + invokeImpl: (command: string) => Promise, + render: (mountedRoot: Root) => Promise, +): Promise<{ + host: HTMLDivElement; + unmount: () => Promise; + invokeSpy: ReturnType; +}> { + const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previousAct = environment.IS_REACT_ACT_ENVIRONMENT; + const invokeSpy = spyOn(native, "invoke").mockImplementation(invokeImpl); + const host = document.createElement("div"); + let root: Root | undefined; + environment.IS_REACT_ACT_ENVIRONMENT = true; + document.body.append(host); + root = createRoot(host); + const mountedRoot = root; + await act(async () => render(mountedRoot)); + const unmount = async () => { + try { + await act(async () => root?.unmount()); + } finally { + host.remove(); + invokeSpy.mockRestore(); + if (previousAct === undefined) delete environment.IS_REACT_ACT_ENVIRONMENT; + else environment.IS_REACT_ACT_ENVIRONMENT = previousAct; + } + }; + return { host, unmount, invokeSpy }; +} + +test("source management renders host state and drives the host commands", async () => { + const sources = [officialSource, communitySource]; + const { host, unmount, invokeSpy } = await mountWith( + async (command) => { + if (command === "declarative_marketplace_sources_state") return state(sources); + if (command === "declarative_marketplace_sources_add") { + sources.push({ ...communitySource, id: "source-added1", name: "example/lithe-plugins" }); + return sources; + } + if (command === "declarative_marketplace_sources_set_enabled") return sources; + if (command === "declarative_marketplace_sources_remove") return sources; + throw new Error(`unexpected command ${command}`); + }, + (mountedRoot) => + Promise.resolve( + mountedRoot.render( + + + , + ), + ), + ); + try { + // Both presets render; only the deletable one offers removal. + const rows = host.querySelectorAll( + '[data-testid="declarative-marketplace-source-list"] > li', + ); + expect(rows.length).toBe(2); + expect( + host.querySelector('[data-testid="declarative-marketplace-source-lithe-official"]'), + ).not.toBeNull(); + expect( + host.querySelector('[data-testid="declarative-marketplace-source-source-39170ddcae1870bf"]'), + ).not.toBeNull(); + const removeButtons = [...rows] + .flatMap((row) => [...row.querySelectorAll("button")]) + .filter((button) => button.textContent === "Remove"); + expect(removeButtons.length).toBe(1); + + // Adding a source forwards the typed URL and refreshes the list. + const input = host.querySelector('input[type="text"]')!; + await act(async () => setInputValue(input, "https://github.com/example/lithe-plugins")); + const addButtons = [...host.querySelectorAll("button")].filter( + (button) => button.textContent === "Add source", + ); + expect(addButtons.length).toBe(1); + await act(async () => addButtons[0].click()); + const addCalls = invokeSpy.mock.calls.filter( + ([command]) => command === "declarative_marketplace_sources_add", + ); + expect(addCalls.length).toBe(1); + expect(addCalls[0][1]).toEqual({ url: "https://github.com/example/lithe-plugins" }); + expect( + host.querySelector('[data-testid="declarative-marketplace-source-source-added1"]'), + ).not.toBeNull(); + + // Removing Lithe Store targets exactly its stable ID. + invokeSpy.mockClear(); + await act(async () => removeButtons[0].click()); + const removeCalls = invokeSpy.mock.calls.filter( + ([command]) => command === "declarative_marketplace_sources_remove", + ); + expect(removeCalls.length).toBe(1); + expect(removeCalls[0][1]).toEqual({ sourceId: "source-39170ddcae1870bf" }); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + } finally { + await unmount(); + } +}); + +// Operations must stay unreachable until the first load resolves: with the +// initial request still pending, the add button is disabled and only the +// state command has been issued. +test("operations stay disabled while the first load is pending", async () => { + const pending = deferred(); + const { host, unmount, invokeSpy } = await mountWith( + async (command) => { + if (command === "declarative_marketplace_sources_state") return pending.promise; + throw new Error(`unexpected command ${command}`); + }, + (mountedRoot) => + Promise.resolve( + mountedRoot.render( + + + , + ), + ), + ); + try { + const addButton = [...host.querySelectorAll("button")].find( + (button) => button.textContent === "Add source", + ); + expect(addButton).toBeDefined(); + const input = host.querySelector('input[type="text"]')!; + // The input itself is the ready gate while nothing has loaded. + expect(input.disabled).toBe(true); + expect( + invokeSpy.mock.calls.every( + ([command]) => command === "declarative_marketplace_sources_state", + ), + ).toBe(true); + await act(async () => pending.resolve(state([officialSource, communitySource]))); + expect(input.disabled).toBe(false); + await act(async () => setInputValue(input, "https://github.com/example/lithe-plugins")); + expect(addButton!.disabled).toBe(false); + } finally { + await unmount(); + } +}); + +// StrictMode double-invokes the mount effect, so two list requests race. +// Whichever resolves first must lose: the later generation wins even when +// the older response arrives last. +test("a late older response cannot overwrite a newer generation", async () => { + const first = deferred(); + const second = deferred(); + const responses = [first.promise, second.promise]; + let call = 0; + const { host, unmount } = await mountWith( + async (command) => { + if (command !== "declarative_marketplace_sources_state") + throw new Error(`unexpected ${command}`); + const index = call++; + return responses[Math.min(index, responses.length - 1)]; + }, + async (mountedRoot) => { + await act(async () => + mountedRoot.render( + + + + + , + ), + ); + }, + ); + try { + expect(call).toBe(2, "StrictMode double-invokes the mount effect"); + // The newer generation resolves first with the fresher data... + await act(async () => second.resolve(state([officialSource, communitySource]))); + expect( + host.querySelector('[data-testid="declarative-marketplace-source-source-39170ddcae1870bf"]'), + ).not.toBeNull(); + // ...then the stale first response arrives with an extra source and must + // be discarded instead of overwriting the newer result. + await act(async () => + first.resolve( + state([ + { ...officialSource }, + { ...communitySource }, + { ...communitySource, id: "source-stale1", name: "Stale source" }, + ]), + ), + ); + expect( + host.querySelector('[data-testid="declarative-marketplace-source-source-stale1"]'), + ).toBeNull(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + } finally { + await unmount(); + } +}); + +// A response that arrives after unmount must not touch the component. +test("a response resolving after unmount is dropped quietly", async () => { + const pending = deferred(); + const { unmount } = await mountWith( + async (command) => { + if (command !== "declarative_marketplace_sources_state") + throw new Error(`unexpected ${command}`); + return pending.promise; + }, + (mountedRoot) => + Promise.resolve( + mountedRoot.render( + + + , + ), + ), + ); + await unmount(); + await act(async () => + pending.resolve( + state([officialSource, communitySource], "plugin.marketplace.sources.corrupt: diagnosed"), + ), + ); + expect(consoleErrorSpy).not.toHaveBeenCalled(); +}); + +// Host rejections carry stable plugin.* codes; the UI must show the +// localized message instead of the code, and clear the busy state so the +// user can retry. +test("operation failures map to localized messages", async () => { + const { host, unmount, invokeSpy } = await mountWith( + async (command) => { + if (command === "declarative_marketplace_sources_state") + return state([officialSource, communitySource]); + if (command === "declarative_marketplace_sources_remove") { + throw new Error("plugin.marketplace.sourceAlreadyAdded: Lithe Store"); + } + throw new Error(`unexpected command ${command}`); + }, + (mountedRoot) => + Promise.resolve( + mountedRoot.render( + + + , + ), + ), + ); + try { + const removeButton = [...host.querySelectorAll("button")].find( + (button) => button.textContent === "Remove", + ); + expect(removeButton).toBeDefined(); + await act(async () => removeButton!.click()); + const alert = host.querySelector('[role="alert"]'); + expect(alert?.textContent).toBe("That source has already been added."); + expect(alert?.textContent).not.toContain("plugin.marketplace"); + expect(removeButton!.disabled).toBe(false); + expect( + invokeSpy.mock.calls.filter( + ([command]) => command === "declarative_marketplace_sources_remove", + ).length, + ).toBe(1); + } finally { + await unmount(); + } +}); + +// A failed first read owns a retry link. Once that retry succeeds, the old +// load diagnosis must leave the UI instead of surviving beside valid data. +test("a successful retry clears the previous load error", async () => { + let readCalls = 0; + const { host, unmount } = await mountWith( + async (command) => { + if (command !== "declarative_marketplace_sources_state") { + throw new Error(`unexpected command ${command}`); + } + readCalls += 1; + if (readCalls === 1) throw new Error("temporary read failure"); + return state([officialSource, communitySource]); + }, + (mountedRoot) => + Promise.resolve( + mountedRoot.render( + + + , + ), + ), + ); + try { + const retry = [...host.querySelectorAll("button")].find( + (button) => button.textContent === "Retry", + ); + expect(retry).toBeDefined(); + expect(host.querySelector('[role="alert"]')?.textContent).toContain( + "The source list could not be loaded.", + ); + + await act(async () => retry!.click()); + + expect(readCalls).toBe(2); + expect(host.querySelector('[role="alert"]')).toBeNull(); + expect( + host.querySelector('[data-testid="declarative-marketplace-source-source-39170ddcae1870bf"]'), + ).not.toBeNull(); + } finally { + await unmount(); + } +}); + +// Reads are also used to re-localize state after a language switch. They may +// refresh the list, but they cannot dismiss an actionable mutation failure. +test.each(["before mutation", "after mutation"] as const)( + "a language-switch read started %s preserves the mutation error", + async (readOrder) => { + let switchLanguage: (language: DisplayLanguage) => void = () => {}; + function Harness() { + const [language, setLanguage] = useState("en-US"); + switchLanguage = setLanguage; + return ( + + + + ); + } + let readCalls = 0; + const languageRead = deferred(); + const { host, unmount } = await mountWith( + async (command) => { + if (command === "declarative_marketplace_sources_state") { + readCalls += 1; + return readCalls === 1 ? state([officialSource, communitySource]) : languageRead.promise; + } + if (command === "declarative_marketplace_sources_remove") { + throw new Error("plugin.marketplace.sourceNotFound: stale source"); + } + throw new Error(`unexpected command ${command}`); + }, + (mountedRoot) => Promise.resolve(mountedRoot.render()), + ); + try { + if (readOrder === "before mutation") { + await act(async () => switchLanguage("zh-CN")); + } + const removeButton = [...host.querySelectorAll("button")].find( + (button) => button.textContent === "Remove" || button.textContent === "删除", + ); + expect(removeButton).toBeDefined(); + await act(async () => removeButton!.click()); + const mutationError = host.querySelector('[role="alert"]')?.textContent; + expect(mutationError).toBeTruthy(); + if (readOrder === "after mutation") { + expect(mutationError).toBe("That source no longer exists."); + await act(async () => switchLanguage("zh-CN")); + } + await act(async () => languageRead.resolve(state([officialSource, communitySource]))); + + expect(readCalls).toBe(2); + expect(host.querySelector('[role="alert"]')?.textContent).toBe(mutationError); + } finally { + await act(async () => languageRead.resolve(state([officialSource, communitySource]))); + await unmount(); + } + }, +); + +// The in-flight gate is shared by the keyboard and pointer paths: while one +// mutation is pending, a second Enter and the Add button alike must not +// reach the host again. +test("an in-flight add is serialized and preserves a newer input draft", async () => { + const pending = deferred(); + let addCalls = 0; + const { host, unmount } = await mountWith( + async (command) => { + if (command === "declarative_marketplace_sources_state") + return state([officialSource, communitySource]); + if (command === "declarative_marketplace_sources_add") { + addCalls += 1; + if (addCalls === 1) return pending.promise; + throw new Error("second add must never happen"); + } + throw new Error(`unexpected command ${command}`); + }, + (mountedRoot) => + Promise.resolve( + mountedRoot.render( + + + , + ), + ), + ); + try { + const input = host.querySelector('input[type="text"]')!; + await act(async () => setInputValue(input, "https://github.com/example/lithe-plugins")); + await act(async () => + input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })), + ); + expect(addCalls).toBe(1); + // The mutation is in flight: repeated Enter and the Add button are both + // synchronous no-ops (the in-flight gate, not just the disabled state). + await act(async () => { + input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + [...host.querySelectorAll("button")] + .find((button) => button.textContent === "Add source")! + .click(); + }); + expect(addCalls).toBe(1); + await act(async () => setInputValue(input, "https://github.com/example/next-plugins")); + await act(async () => + pending.resolve([ + { ...officialSource }, + { ...communitySource }, + { ...communitySource, id: "source-added1", name: "example/lithe-plugins" }, + ]), + ); + expect( + host.querySelector('[data-testid="declarative-marketplace-source-source-added1"]'), + ).not.toBeNull(); + expect(addCalls).toBe(1); + expect(input.value).toBe("https://github.com/example/next-plugins"); + } finally { + await unmount(); + } +}); + +// A language switch re-reads the list. Read and mutation lifetimes are +// separate: whichever resolves first, the mutation's authoritative list +// wins, the busy flag clears, and the user can operate again. +test("a language-switch read during a mutation cannot clobber it", async () => { + let switchLanguage: (language: DisplayLanguage) => void = () => {}; + function Harness() { + const [language, setLanguage] = useState("en-US"); + switchLanguage = setLanguage; + return ( + + + + ); + } + const mutation = deferred(); + const languageRead = deferred(); + let readCalls = 0; + let removeCalls = 0; + const { host, unmount, invokeSpy } = await mountWith( + async (command) => { + if (command === "declarative_marketplace_sources_state") { + readCalls += 1; + if (readCalls === 1) return state([officialSource, communitySource]); + return languageRead.promise; + } + if (command === "declarative_marketplace_sources_add") return mutation.promise; + if (command === "declarative_marketplace_sources_remove") { + removeCalls += 1; + return [{ ...officialSource }]; + } + throw new Error(`unexpected command ${command}`); + }, + (mountedRoot) => + Promise.resolve( + mountedRoot.render( + + + , + ), + ), + ); + try { + const input = host.querySelector('input[type="text"]')!; + await act(async () => setInputValue(input, "https://github.com/Lithe-IDEA/Lithe-store")); + await act(async () => + input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })), + ); + // The mutation is in flight; switching language starts a fresh read. + await act(async () => switchLanguage("zh-CN")); + expect(readCalls).toBe(2); + // The read resolves first with the pre-mutation list: it must be + // discarded instead of hiding the pending mutation's result. + await act(async () => languageRead.resolve(state([officialSource, communitySource]))); + expect( + host.querySelector('[data-testid="declarative-marketplace-source-source-added1"]'), + ).toBeNull(); + // The mutation resolves afterwards and owns the list. + await act(async () => + mutation.resolve([ + { ...officialSource }, + { ...communitySource }, + { ...communitySource, id: "source-added1", name: "Lithe Store" }, + ]), + ); + expect( + host.querySelector('[data-testid="declarative-marketplace-source-source-added1"]'), + ).not.toBeNull(); + // The busy flag cleared with the mutation, so the user can operate + // again immediately; the UI is now in Chinese after the switch, and the + // Remove button reflects the busy state. + const removeButton = [...host.querySelectorAll("button")].find( + (button) => button.textContent === "删除", + )!; + expect(removeButton.disabled).toBe(false); + await act(async () => removeButton.click()); + expect(removeCalls).toBe(1); + } finally { + await unmount(); + } +}); + +// Reverse resolve order: the mutation completes first and the language read +// afterwards must not roll the list back. +test("a language-switch read after the mutation cannot roll it back", async () => { + let switchLanguage: (language: DisplayLanguage) => void = () => {}; + function Harness() { + const [language, setLanguage] = useState("en-US"); + switchLanguage = setLanguage; + return ( + + + + ); + } + const mutation = deferred(); + const languageRead = deferred(); + let readCalls = 0; + const { host, unmount } = await mountWith( + async (command) => { + if (command === "declarative_marketplace_sources_state") { + readCalls += 1; + if (readCalls === 1) return state([officialSource, communitySource]); + return languageRead.promise; + } + if (command === "declarative_marketplace_sources_add") return mutation.promise; + throw new Error(`unexpected command ${command}`); + }, + (mountedRoot) => + Promise.resolve( + mountedRoot.render( + + + , + ), + ), + ); + try { + const input = host.querySelector('input[type="text"]')!; + await act(async () => setInputValue(input, "https://github.com/Lithe-IDEA/Lithe-store")); + await act(async () => + input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })), + ); + await act(async () => switchLanguage("zh-CN")); + expect(readCalls).toBe(2); + // The mutation resolves first... + await act(async () => + mutation.resolve([ + { ...officialSource }, + { ...communitySource }, + { ...communitySource, id: "source-added1", name: "Lithe Store" }, + ]), + ); + expect( + host.querySelector('[data-testid="declarative-marketplace-source-source-added1"]'), + ).not.toBeNull(); + // ...then the stale read lands and must not roll the list back. + await act(async () => languageRead.resolve(state([officialSource, communitySource]))); + expect( + host.querySelector('[data-testid="declarative-marketplace-source-source-added1"]'), + ).not.toBeNull(); + } finally { + await unmount(); + } +}); diff --git a/windows/tauri/src/features/plugins/declarative-marketplace-sources.ts b/windows/tauri/src/features/plugins/declarative-marketplace-sources.ts new file mode 100644 index 0000000000..826573b616 --- /dev/null +++ b/windows/tauri/src/features/plugins/declarative-marketplace-sources.ts @@ -0,0 +1,68 @@ +import { invoke } from "@/platform/tauri-core"; + +/** + * Host API for declarative marketplace source management. The Rust + * `plugin_host::marketplace_sources` module owns the list: preset sources are + * rebuilt from host constants, the official source can be disabled but never + * removed, and URL resolution is decided by Core + * (`plugins.marketplace.resolveSource`). Index fetching, downloads, and + * installation are later M2 slices and deliberately absent here. + */ + +export interface DeclarativeMarketplaceSource { + id: string; + name: string; + /** What the user entered, or the official index URL for a preset. */ + input: string; + indexURL: string; + repository: string | null; + isEnabled: boolean; + kind: "official" | "thirdParty"; + isOfficial: boolean; + deletable: boolean; +} + +export interface DeclarativeMarketplaceSourcesState { + sources: DeclarativeMarketplaceSource[]; + /** + * Present when the persisted sources file was corrupted: the original + * bytes were preserved next to the store and the list rebuilt from + * presets. The user's data is never silently reset. + */ + problem: string | null; +} + +/** Returns the current sources; presets are rebuilt by the host. */ +export function listDeclarativeMarketplaceSources(): Promise { + return invoke("declarative_marketplace_sources_state"); +} + +/** + * Resolves and adds a third-party source through Core. Rejects duplicates + * (same stable ID or same index URL in any casing) with a stable + * `plugin.marketplace.sourceAlreadyAdded` error, and passes Core's + * `plugin.source.invalid` rejections through. + */ +export function addDeclarativeMarketplaceSource(url: string): Promise { + return invoke("declarative_marketplace_sources_add", { url }); +} + +/** Enables or disables one source; unknown IDs are a host-side no-op. */ +export function setDeclarativeMarketplaceSourceEnabled( + sourceId: string, + enabled: boolean, +): Promise { + return invoke("declarative_marketplace_sources_set_enabled", { + sourceId, + enabled, + }); +} + +/** + * Removes a deletable source. The official source is refused by the host + * (`plugin.marketplace.officialSourceCannotBeRemoved`); removing Lithe Store + * persists its removal across restarts. + */ +export function removeDeclarativeMarketplaceSource(sourceId: string): Promise { + return invoke("declarative_marketplace_sources_remove", { sourceId }); +} diff --git a/windows/tauri/src/features/plugins/declarative-plugin-lifecycle.ts b/windows/tauri/src/features/plugins/declarative-plugin-lifecycle.ts index d4b9c2341b..14fa42bfad 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-lifecycle.ts +++ b/windows/tauri/src/features/plugins/declarative-plugin-lifecycle.ts @@ -1,11 +1,19 @@ /** Per-window revocation, set synchronously before draining owned processes. */ const blocked = new Set(); const revisions = new Map(); +const activityListeners = new Set<(pluginId: string) => void>(); export function setPluginActive(pluginId: string, active: boolean): void { revisions.set(pluginId, (revisions.get(pluginId) ?? 0) + 1); if (active) blocked.delete(pluginId); else blocked.add(pluginId); + for (const listener of activityListeners) listener(pluginId); +} + +/** Observe the same synchronous revocation used by launch-plan guards. */ +export function subscribePluginActivity(listener: (pluginId: string) => void): () => void { + activityListeners.add(listener); + return () => activityListeners.delete(listener); } export function assertPluginActive(pluginId: string): void { diff --git a/windows/tauri/src/features/plugins/declarative-plugins-section.tsx b/windows/tauri/src/features/plugins/declarative-plugins-section.tsx index fd429c7bfe..47bf14dc1b 100644 --- a/windows/tauri/src/features/plugins/declarative-plugins-section.tsx +++ b/windows/tauri/src/features/plugins/declarative-plugins-section.tsx @@ -19,6 +19,8 @@ import { type PreparedDeclarativeInstall, } from "./declarative-plugin-launch"; import { DeclarativePluginSettingsEditor } from "./declarative-plugin-settings-editor"; +import { DeclarativeMarketplaceSources } from "./declarative-marketplace-sources-section"; +import { PluginDetailsPanel, type PluginDetailsSelection } from "./details"; import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store"; /** @@ -35,16 +37,27 @@ export function DeclarativePluginsSection() { const [pending, setPending] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [selection, setSelection] = useState(null); + const [detailsRevision, setDetailsRevision] = useState(0); const workspacePath = useWorkspaceTabsStore((state) => state.projectTabs.find((tab) => tab.isActive)?.path ?? null, ); const refresh = useCallback(async () => { await rescanDeclarativePlugins(); - setPlugins(await listDeclarativePlugins()); + const next = await listDeclarativePlugins(); + setPlugins(next); + setSelection((current) => current && next.some((plugin) => + plugin.pluginId === current.pluginId && plugin.source === current.source, + ) ? current : null); + setDetailsRevision((revision) => revision + 1); setWebWindows(await listDeclarativeWebWindows()); }, []); + const handleChanged = useCallback(() => { + void refresh().catch((reason: unknown) => setError(String(reason))); + }, [refresh]); + useEffect(() => { refresh().catch((reason: unknown) => setError(String(reason))); }, [refresh]); @@ -125,7 +138,9 @@ export function DeclarativePluginsSection() { key={`${plugin.source}-${plugin.pluginId}`} plugin={plugin} workspacePath={workspacePath} - onChanged={() => void refresh()} + onChanged={handleChanged} + selected={selection?.pluginId === plugin.pluginId && selection.source === plugin.source} + onSelect={() => setSelection({ pluginId: plugin.pluginId, source: plugin.source })} /> ))} {!plugins.length && ( @@ -134,6 +149,14 @@ export function DeclarativePluginsSection() { )} +
    + +
    {webWindows.length > 0 && (

    @@ -146,6 +169,7 @@ export function DeclarativePluginsSection() {

    )} +
    ); } @@ -257,10 +281,14 @@ function DeclarativePluginRow({ plugin, workspacePath, onChanged, + selected, + onSelect, }: { plugin: DeclarativePluginSummary; workspacePath: string | null; onChanged: () => void; + selected: boolean; + onSelect: () => void; }) { const { t } = useTranslation(); const [busy, setBusy] = useState(false); @@ -289,7 +317,9 @@ function DeclarativePluginRow({

    - {plugin.displayName}{" "} + {" "} {plugin.version} · {t(`settings.declarativePlugins.source.${plugin.source}`)} diff --git a/windows/tauri/src/features/plugins/details/index.ts b/windows/tauri/src/features/plugins/details/index.ts new file mode 100644 index 0000000000..ac8c0f9804 --- /dev/null +++ b/windows/tauri/src/features/plugins/details/index.ts @@ -0,0 +1,3 @@ +export * from "./plugin-details.types"; +export * from "./plugin-details.service"; +export * from "./plugin-details-view"; diff --git a/windows/tauri/src/features/plugins/details/plugin-details-view.test.tsx b/windows/tauri/src/features/plugins/details/plugin-details-view.test.tsx new file mode 100644 index 0000000000..f79e08a6b1 --- /dev/null +++ b/windows/tauri/src/features/plugins/details/plugin-details-view.test.tsx @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; +import { act, StrictMode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import type { PluginDetailsModel } from "./plugin-details.types"; + +mock.module("@/platform/tauri-core", () => ({ invoke: mock() })); +mock.module("../declarative-plugin-launch", () => ({ + rollbackDeclarativePlugin: mock(), + setDeclarativePluginEnabled: mock(), + uninstallDeclarativePlugin: mock(), +})); + +mock.module("@/i18n/locale-provider", () => ({ + useTranslation: () => ({ + language: "en-US", + t: (key: string, values?: Record) => + values?.index ? `${key} ${values.index}` : key, + }), +})); +mock.module("../declarative-plugin-settings-editor", () => ({ + DeclarativePluginSettingsEditor: ({ pluginId }: { pluginId: string }) => ( +

    shared settings for {pluginId}
    + ), +})); + +const { PluginDetailsPanel, PluginDetailsView } = await import("./plugin-details-view"); + +let restoreDom: (() => void) | null = null; +let root: Root | null = null; +let host: HTMLDivElement | null = null; + +beforeEach(() => { + restoreDom = installHappyDom(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + host?.remove(); + restoreDom?.(); + root = null; + host = null; + restoreDom = null; +}); + +function details(): PluginDetailsModel { + return { + identity: { pluginId: "io.example.complete", source: "installed", version: "2.0.0" }, + displayName: "Complete Plugin", + vendorName: "Example", + description: "Default description", + localizedDescriptions: { "en-US": "Localized description" }, + apiVersion: 1, + enabled: true, + status: "accepted", + rejection: null, + settings: [{ id: "heap", type: "integer", title: "Heap", default: 512 }], + primaryLanguageId: "demo", + features: [{ kind: "languageServer", subject: "demo", title: "Demo", availability: "available", detail: { launchKind: "packaged" } }], + permissions: ["languageServer.launch"], + programs: [{ kind: "packagedLanguageServer", arguments: ["bin/server", "--stdio"], validationArguments: ["--version"], initializationOptions: { trace: true } }], + versions: { + current: { + version: "2.0.0", + origin: "community", + channel: "disk", + sourceId: null, + sourceName: null, + repository: null, + fileName: "complete.zip", + archiveSha256: "archive", + contentDigest: "content", + verification: { status: "approvedDigest", keyId: null }, + }, + previous: { + version: "1.0.0", + origin: "marketplace", + channel: "marketplace", + sourceId: "store", + sourceName: "Store", + repository: "https://example.invalid", + fileName: null, + archiveSha256: "old-archive", + contentDigest: "old-content", + verification: { status: "unknown", keyId: null }, + }, + updateStatus: "unknown", + }, + operations: { canSetEnabled: true, canUninstall: true, canRollback: true }, + }; +} + +async function renderView() { + await act(async () => { + root?.render( + {}} + />, + ); + }); +} + +test("renders five keyboard tabs and complete program declarations", async () => { + await renderView(); + const tabs = [...host!.querySelectorAll('[role="tab"]')]; + expect(tabs).toHaveLength(5); + await act(async () => tabs[3]!.click()); + expect(host!.querySelector('[role="tabpanel"]')?.getAttribute("aria-labelledby")).toBe(tabs[3]!.id); + expect(host!.textContent).toContain("languageServer.launch"); + expect(host!.textContent).toContain("bin/server"); + expect(host!.textContent).toContain("validationArguments"); + expect(host!.textContent).toContain("initializationOptions"); + + await act(async () => { + tabs[3]!.focus(); + tabs[3]!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + }); + expect(tabs[4]!.getAttribute("aria-selected")).toBe("true"); + expect(host!.textContent).toContain("Store"); + expect(host!.textContent).toContain("settings.declarativePluginDetails.update.unknown"); +}); + +test("the settings tab reuses the shared settings editor", async () => { + await renderView(); + const tabs = [...host!.querySelectorAll('[role="tab"]')]; + await act(async () => tabs[2]!.click()); + expect(host!.querySelector('[data-testid="plugin-details-settings-editor"]')).not.toBeNull(); + expect(host!.querySelector('[data-testid="shared-settings-editor"]')?.textContent).toContain("io.example.complete"); +}); + +test("no current selection has a stable empty state and never invokes the service", async () => { + const service = { + load: mock(async () => details()), + setEnabled: mock(async () => {}), + uninstall: mock(async () => {}), + rollback: mock(async () => {}), + }; + await act(async () => root?.render( + , + )); + expect(host!.textContent).toContain("settings.declarativePluginDetails.noSelection"); + expect(service.load).not.toHaveBeenCalled(); +}); + +test("StrictMode effect replay creates a live detail session", async () => { + const service = { + load: mock(async () => details()), + setEnabled: mock(async () => {}), + uninstall: mock(async () => {}), + rollback: mock(async () => {}), + }; + await act(async () => root?.render( + + + , + )); + expect(host!.textContent).toContain("Complete Plugin"); + expect(host!.querySelectorAll('[role="tab"]')).toHaveLength(5); +}); diff --git a/windows/tauri/src/features/plugins/details/plugin-details-view.tsx b/windows/tauri/src/features/plugins/details/plugin-details-view.tsx new file mode 100644 index 0000000000..ade11831ad --- /dev/null +++ b/windows/tauri/src/features/plugins/details/plugin-details-view.tsx @@ -0,0 +1,479 @@ +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { useTranslation } from "@/i18n/locale-provider"; +import { DeclarativePluginSettingsEditor } from "../declarative-plugin-settings-editor"; +import { + invokePluginDetailsService, + PluginDetailsSession, + type PluginDetailsService, +} from "./plugin-details.service"; +import type { + PluginDetailFeature, + PluginDetailsModel, + PluginDetailsOperation, + PluginDetailsSelection, + PluginDetailsState, + PluginVersionProvenance, +} from "./plugin-details.types"; + +export type PluginDetailTab = "overview" | "features" | "settings" | "permissions" | "versions"; + +const TABS: PluginDetailTab[] = ["overview", "features", "settings", "permissions", "versions"]; + +export interface PluginDetailsPanelProps { + selection: PluginDetailsSelection | null; + workspacePath: string | null; + service?: PluginDetailsService; + onChanged?: (selection: PluginDetailsSelection, workspacePath: string | null) => void; +} + +/** Load-owning wrapper. The management page supplies the selected catalog identity. */ +export function PluginDetailsPanel({ + selection, + workspacePath, + service = invokePluginDetailsService, + onChanged, +}: PluginDetailsPanelProps) { + const { t } = useTranslation(); + const [state, setState] = useState({ + phase: "idle", + selection: null, + workspacePath, + }); + const session = useRef(null); + const selectedPluginId = selection?.pluginId; + const selectedSource = selection?.source; + + useEffect(() => { + const current = new PluginDetailsSession(service, setState, onChanged); + session.current = current; + current.select( + selectedPluginId && selectedSource + ? { pluginId: selectedPluginId, source: selectedSource } + : null, + workspacePath, + ); + return () => { + current.dispose(); + if (session.current === current) session.current = null; + }; + }, [service, onChanged, selectedPluginId, selectedSource, workspacePath]); + + if (state.phase === "idle") { + return ; + } + if (state.phase === "loading") { + return ( +
    + {t("settings.declarativePluginDetails.loading")} +
    + ); + } + if (state.phase === "failed") { + return ( +
    +

    {state.error}

    + +
    + ); + } + return ( + void session.current?.perform(operation, state.details)} + /> + ); +} + +export function PluginDetailsView({ + details, + workspacePath, + operation, + operationError, + onOperation, +}: { + details: PluginDetailsModel; + workspacePath: string | null; + operation: PluginDetailsOperation | null; + operationError: string | null; + onOperation: (operation: PluginDetailsOperation) => void; +}) { + const { language, t } = useTranslation(); + const [tab, setTab] = useState("overview"); + const description = details.localizedDescriptions[language] ?? details.description; + + return ( +
    +
    +
    +
    +

    {details.displayName}

    + +
    +

    + {[details.vendorName, details.identity.version].filter(Boolean).join(" · ")} +

    +
    + + {details.rejection && ( +

    + {details.rejection.code}: {details.rejection.message} +

    + )} + {details.versions.current.origin === "community" && details.status !== "rejected" && ( +

    + {details.versions.current.channel === "disk" + ? t("settings.declarativePluginDetails.notice.diskCommunity") + : t("settings.declarativePluginDetails.notice.community")} +

    + )} + {operationError && ( +

    {operationError}

    + )} +
    + +
    + {tab === "overview" && } + {tab === "features" && } + {tab === "settings" && ( + + )} + {tab === "permissions" && ( + + )} + {tab === "versions" && } +
    +
    + ); +} + +function PluginDetailActions({ + details, + operation, + onOperation, +}: { + details: PluginDetailsModel; + operation: PluginDetailsOperation | null; + onOperation: (operation: PluginDetailsOperation) => void; +}) { + const { t } = useTranslation(); + const disabled = operation !== null; + return ( +
    + {details.operations.canSetEnabled && details.status === "accepted" && ( + + )} + {details.operations.canRollback && ( + + )} + {details.operations.canUninstall && ( + + )} +
    + ); +} + +function PluginDetailTabBar({ + value, + details, + onChange, +}: { + value: PluginDetailTab; + details: PluginDetailsModel; + onChange: (tab: PluginDetailTab) => void; +}) { + const { t } = useTranslation(); + const counts: Partial> = { + features: details.features.length, + settings: details.settings.length, + permissions: details.permissions.length + details.programs.length, + }; + const selectRelative = (event: KeyboardEvent, current: PluginDetailTab) => { + let index = TABS.indexOf(current); + if (event.key === "ArrowRight") index = (index + 1) % TABS.length; + else if (event.key === "ArrowLeft") index = (index - 1 + TABS.length) % TABS.length; + else if (event.key === "Home") index = 0; + else if (event.key === "End") index = TABS.length - 1; + else return; + event.preventDefault(); + onChange(TABS[index]!); + document.getElementById(tabId(details, TABS[index]!))?.focus(); + }; + return ( +
    + {TABS.map((tab) => ( + + ))} +
    + ); +} + +export function OverviewTab({ details, description }: { details: PluginDetailsModel; description: string | null }) { + const { t } = useTranslation(); + return ( +
    + {description &&

    {description}

    } + feature.title).join(", ") || "—"], + [t("settings.declarativePluginDetails.field.source"), sourceText(details.versions.current, t)], + [t("settings.declarativePluginDetails.field.settings"), String(details.settings.length)], + ]} /> +
    + ); +} + +export function FeaturesTab({ features }: { features: PluginDetailFeature[] }) { + const { t } = useTranslation(); + if (!features.length) return ; + return ( +
      + {features.map((feature) => ( +
    • +

      {feature.title}

      +

      + {feature.kind} · {t(`settings.declarativePluginDetails.availability.${feature.availability}`)} +

      + {feature.detail !== null && ( +
      +              {JSON.stringify(feature.detail, null, 2)}
      +            
      + )} +
    • + ))} +
    + ); +} + +export function SettingsTab({ + details, + workspacePath, +}: { + details: PluginDetailsModel; + workspacePath: string | null; +}) { + const { t } = useTranslation(); + if (!details.settings.length) { + return ; + } + if (!details.enabled || details.status !== "accepted") { + return ; + } + return ( +
    + +
    + ); +} + +export function PermissionsAndProgramsTab({ + permissions, + programs, +}: { + permissions: string[]; + programs: unknown[]; +}) { + const { t } = useTranslation(); + return ( +
    +
    +

    {t("settings.declarativePluginDetails.permissions")}

    + {permissions.length ? ( +
      + {permissions.map((permission) => ( +
    • + {t(`settings.declarativePluginDetails.permission.${permission}`)} + {permission} +
    • + ))} +
    + ) : } +
    +
    +

    {t("settings.declarativePluginDetails.programs")}

    + {programs.length ? ( +
    + {programs.map((program, index) => ( +
    + + {t("settings.declarativePluginDetails.program", { index: index + 1 })} + +
    +                  {JSON.stringify(program, null, 2)}
    +                
    +
    + ))} +
    + ) : } +
    +
    + ); +} + +export function VersionsAndSourceTab({ details }: { details: PluginDetailsModel }) { + const { t } = useTranslation(); + return ( +
    + + {details.versions.previous && ( + + )} + +
    + ); +} + +function VersionCard({ title, provenance }: { title: string; provenance: PluginVersionProvenance }) { + const { t } = useTranslation(); + return ( +
    +

    {title}

    + +
    + ); +} + +function DefinitionList({ rows }: { rows: Array<[string, string]> }) { + return ( +
    + {rows.map(([term, value]) => ( +
    +
    {term}
    +
    {value}
    +
    + ))} +
    + ); +} + +function StatusBadge({ details }: { details: PluginDetailsModel }) { + const { t } = useTranslation(); + return ( + + {statusText(details, t)} + + ); +} + +function PluginDetailEmpty({ message }: { message: string }) { + return

    {message}

    ; +} + +function statusText(details: PluginDetailsModel, t: (key: string) => string): string { + if (details.status === "rejected") return t("settings.declarativePluginDetails.status.quarantined"); + return details.enabled + ? t("settings.declarativePluginDetails.status.enabled") + : t("settings.declarativePluginDetails.status.disabled"); +} + +function sourceText( + provenance: PluginVersionProvenance, + t: (key: string) => string, +): string { + const channel = t(`settings.declarativePluginDetails.source.${provenance.channel}`); + return provenance.sourceName + ? `${channel} · ${provenance.sourceName}` + : provenance.fileName + ? `${channel} · ${provenance.fileName}` + : channel; +} + +function verificationText( + provenance: PluginVersionProvenance, + t: (key: string) => string, +): string { + const text = t(`settings.declarativePluginDetails.verification.${provenance.verification.status}`); + return provenance.verification.keyId ? `${text} · ${provenance.verification.keyId}` : text; +} + +function tabId(details: PluginDetailsModel, tab: PluginDetailTab): string { + return `plugin-details-${safeId(details.identity.pluginId)}-${tab}`; +} + +function panelId(details: PluginDetailsModel): string { + return `plugin-details-${safeId(details.identity.pluginId)}-panel`; +} + +function safeId(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, "-"); +} diff --git a/windows/tauri/src/features/plugins/details/plugin-details.service.test.ts b/windows/tauri/src/features/plugins/details/plugin-details.service.test.ts new file mode 100644 index 0000000000..d7b02bb6b3 --- /dev/null +++ b/windows/tauri/src/features/plugins/details/plugin-details.service.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { PluginDetailsService } from "./plugin-details.service"; +import type { + PluginDetailsModel, + PluginDetailsSelection, + PluginDetailsState, +} from "./plugin-details.types"; + +mock.module("@/platform/tauri-core", () => ({ invoke: mock(async () => null) })); +mock.module("../declarative-plugin-launch", () => ({ + rollbackDeclarativePlugin: mock(async () => {}), + setDeclarativePluginEnabled: mock(async () => {}), + uninstallDeclarativePlugin: mock(async () => {}), +})); + +const { PluginDetailsSession } = await import("./plugin-details.service"); + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((accept, decline) => { + resolve = accept; + reject = decline; + }); + return { promise, resolve, reject }; +} + +function model(pluginId: string, source: "bundled" | "installed" = "installed"): PluginDetailsModel { + return { + identity: { pluginId, source, version: "1.0.0" }, + displayName: pluginId, + vendorName: null, + description: null, + localizedDescriptions: {}, + apiVersion: 1, + enabled: true, + status: "accepted", + rejection: null, + settings: [], + primaryLanguageId: null, + features: [], + permissions: [], + programs: [], + versions: { + current: { + version: "1.0.0", + origin: source === "bundled" ? "bundled" : "community", + channel: source === "bundled" ? "bundled" : "disk", + sourceId: null, + sourceName: null, + repository: null, + fileName: null, + archiveSha256: null, + contentDigest: null, + verification: { status: source === "bundled" ? "hostSeal" : "approvedDigest", keyId: null }, + }, + previous: null, + updateStatus: "unknown", + }, + operations: { canSetEnabled: source === "installed", canUninstall: source === "installed", canRollback: false }, + }; +} + +function fixture(load: PluginDetailsService["load"]) { + const states: PluginDetailsState[] = []; + const service: PluginDetailsService = { + load, + setEnabled: mock(async () => {}), + uninstall: mock(async () => {}), + rollback: mock(async () => {}), + }; + const changed = mock(() => {}); + return { states, service, changed, session: new PluginDetailsSession(service, (state) => states.push(state), changed) }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("plugin detail session ownership", () => { + test("a late success from the old plugin cannot replace the new selection", async () => { + const first = deferred(); + const second = deferred(); + const f = fixture(({ pluginId }) => pluginId === "first" ? first.promise : second.promise); + f.session.select({ pluginId: "first", source: "installed" }, "C:/one"); + f.session.select({ pluginId: "second", source: "installed" }, "C:/two"); + second.resolve(model("second")); + await flushMicrotasks(); + const ready = f.states[f.states.length - 1]; + expect(ready?.phase).toBe("ready"); + if (ready?.phase === "ready") expect(ready.details.identity.pluginId).toBe("second"); + const count = f.states.length; + first.resolve(model("first")); + await flushMicrotasks(); + expect(f.states).toHaveLength(count); + }); + + test("a late failure and a same-plugin result from the old workspace are ignored", async () => { + const first = deferred(); + const second = deferred(); + let call = 0; + const f = fixture(() => (++call === 1 ? first.promise : second.promise)); + const selected: PluginDetailsSelection = { pluginId: "same", source: "installed" }; + f.session.select(selected, "C:/old"); + f.session.select(selected, "C:/new"); + first.reject(new Error("old workspace failed")); + second.resolve(model("same")); + await flushMicrotasks(); + expect(f.states.some((state) => state.phase === "failed")).toBe(false); + const last = f.states[f.states.length - 1]; + expect(last?.phase).toBe("ready"); + if (last?.phase === "ready") expect(last.workspacePath).toBe("C:/new"); + }); + + test("disposing the panel suppresses every pending write", async () => { + const request = deferred(); + const f = fixture(() => request.promise); + f.session.select({ pluginId: "closing", source: "installed" }, null); + const count = f.states.length; + f.session.dispose(); + request.resolve(model("closing")); + await flushMicrotasks(); + expect(f.states).toHaveLength(count); + }); + + test("an operation failure stays on the captured plugin and is actionable", async () => { + const f = fixture(async () => model("broken")); + f.service.setEnabled = mock(async () => { throw new Error("could not stop the language server"); }); + const selected: PluginDetailsSelection = { pluginId: "broken", source: "installed" }; + f.session.select(selected, "C:/work"); + await flushMicrotasks(); + await f.session.perform("disable", model("broken")); + const last = f.states[f.states.length - 1]; + expect(last?.phase).toBe("ready"); + if (last?.phase === "ready") { + expect(last.selection.pluginId).toBe("broken"); + expect(last.error).toContain("could not stop"); + expect(last.operation).toBeNull(); + } + expect(f.changed).not.toHaveBeenCalled(); + }); + + test("an operation completion cannot reload or publish after selection changes", async () => { + const operation = deferred(); + const f = fixture(async ({ pluginId }) => model(pluginId)); + f.service.setEnabled = mock(() => operation.promise); + const first: PluginDetailsSelection = { pluginId: "first", source: "installed" }; + f.session.select(first, "C:/one"); + await flushMicrotasks(); + const pending = f.session.perform("disable", model("first")); + f.session.select({ pluginId: "second", source: "installed" }, "C:/two"); + await flushMicrotasks(); + operation.resolve(); + await pending; + const last = f.states[f.states.length - 1]; + expect(last?.phase).toBe("ready"); + if (last?.phase === "ready") expect(last.details.identity.pluginId).toBe("second"); + expect(f.changed).not.toHaveBeenCalled(); + }); + + test("successful uninstall clears details without requesting the removed package", async () => { + const load = mock(async () => model("removed")); + const f = fixture(load); + f.session.select({ pluginId: "removed", source: "installed" }, "C:/work"); + await flushMicrotasks(); + await f.session.perform("uninstall", model("removed")); + expect(f.service.uninstall).toHaveBeenCalledWith("removed"); + expect(load).toHaveBeenCalledTimes(1); + expect(f.states[f.states.length - 1]?.phase).toBe("idle"); + expect(f.changed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/windows/tauri/src/features/plugins/details/plugin-details.service.ts b/windows/tauri/src/features/plugins/details/plugin-details.service.ts new file mode 100644 index 0000000000..08f22d0252 --- /dev/null +++ b/windows/tauri/src/features/plugins/details/plugin-details.service.ts @@ -0,0 +1,153 @@ +import { invoke } from "@/platform/tauri-core"; +import { + rollbackDeclarativePlugin, + setDeclarativePluginEnabled, + uninstallDeclarativePlugin, +} from "../declarative-plugin-launch"; +import { + samePluginSelection, + type PluginDetailsModel, + type PluginDetailsOperation, + type PluginDetailsSelection, + type PluginDetailsState, +} from "./plugin-details.types"; + +export interface PluginDetailsService { + load(selection: PluginDetailsSelection): Promise; + setEnabled(pluginId: string, enabled: boolean): Promise; + uninstall(pluginId: string): Promise; + rollback(pluginId: string): Promise; +} + +export const invokePluginDetailsService: PluginDetailsService = { + load: ({ pluginId, source }) => + invoke("declarative_plugins_details", { pluginId, source }), + setEnabled: setDeclarativePluginEnabled, + uninstall: uninstallDeclarativePlugin, + rollback: rollbackDeclarativePlugin, +}; + +/** + * Owns one mounted detail panel. Every request captures both plugin selection + * and workspace identity. Selection changes and disposal advance the token, + * so an old success or failure cannot write into the new/closed panel. + */ +export class PluginDetailsSession { + #selection: PluginDetailsSelection | null = null; + #workspacePath: string | null = null; + #generation = 0; + #disposed = false; + + constructor( + private readonly service: PluginDetailsService, + private readonly publish: (state: PluginDetailsState) => void, + private readonly changed?: (selection: PluginDetailsSelection, workspacePath: string | null) => void, + ) {} + + select(selection: PluginDetailsSelection | null, workspacePath: string | null): void { + this.#selection = selection; + this.#workspacePath = workspacePath; + const generation = ++this.#generation; + if (!selection) { + this.#emit({ phase: "idle", selection: null, workspacePath }); + return; + } + const captured = { ...selection }; + this.#emit({ phase: "loading", selection: captured, workspacePath }); + void this.service.load(captured).then( + (details) => { + if (!this.#current(generation, captured, workspacePath)) return; + this.#emit({ + phase: "ready", + selection: captured, + workspacePath, + details, + operation: null, + error: null, + }); + }, + (reason: unknown) => { + if (!this.#current(generation, captured, workspacePath)) return; + this.#emit({ + phase: "failed", + selection: captured, + workspacePath, + error: errorMessage(reason), + }); + }, + ); + } + + async perform(operation: PluginDetailsOperation, details: PluginDetailsModel): Promise { + const selection = this.#selection ? { ...this.#selection } : null; + const workspacePath = this.#workspacePath; + if (!selection || !samePluginSelection(selection, details.identity)) return; + const generation = this.#generation; + this.#emit({ + phase: "ready", + selection, + workspacePath, + details, + operation, + error: null, + }); + try { + if (operation === "enable" || operation === "disable") { + await this.service.setEnabled(selection.pluginId, operation === "enable"); + } else if (operation === "uninstall") { + await this.service.uninstall(selection.pluginId); + } else { + await this.service.rollback(selection.pluginId); + } + if (!this.#current(generation, selection, workspacePath)) return; + this.changed?.(selection, workspacePath); + if (!this.#current(generation, selection, workspacePath)) return; + if (operation === "uninstall") { + // The removed package has no detail snapshot to reload. Leave a + // stable empty panel while its owner refreshes the catalog. + this.select(null, workspacePath); + return; + } + // Reload from the host after mutation; never synthesize version or trust state. + this.select(selection, workspacePath); + } catch (reason) { + if (!this.#current(generation, selection, workspacePath)) return; + this.#emit({ + phase: "ready", + selection, + workspacePath, + details, + operation: null, + error: errorMessage(reason), + }); + } + } + + dispose(): void { + this.#disposed = true; + this.#selection = null; + this.#generation += 1; + } + + #current( + generation: number, + selection: PluginDetailsSelection, + workspacePath: string | null, + ): boolean { + return !this.#disposed + && generation === this.#generation + && samePluginSelection(this.#selection, selection) + && this.#workspacePath === workspacePath; + } + + #emit(state: PluginDetailsState): void { + if (!this.#disposed) this.publish(state); + } +} + +function errorMessage(reason: unknown): string { + if (typeof reason === "object" && reason !== null && "message" in reason) { + return String((reason as { message: unknown }).message); + } + return String(reason); +} diff --git a/windows/tauri/src/features/plugins/details/plugin-details.types.ts b/windows/tauri/src/features/plugins/details/plugin-details.types.ts new file mode 100644 index 0000000000..0d44795bac --- /dev/null +++ b/windows/tauri/src/features/plugins/details/plugin-details.types.ts @@ -0,0 +1,101 @@ +import type { DeclarativePluginRejection, DeclarativePluginSetting } from "../declarative-plugin-launch"; + +export type PluginCatalogSource = "bundled" | "installed"; + +export interface PluginDetailsSelection { + pluginId: string; + source: PluginCatalogSource; +} + +export interface PluginDetailsIdentity extends PluginDetailsSelection { + version: string; +} + +export interface PluginDetailFeature { + kind: + | "language" + | "languageServer" + | "run" + | "test" + | "buildSystem" + | "projectGenerator" + | "toolWindow" + | "command"; + subject: string; + title: string; + availability: "available" | "declaredOnly"; + detail: unknown | null; +} + +export interface PluginProvenanceVerification { + status: "publisherSignature" | "hostSeal" | "approvedDigest" | "rejected" | "unknown"; + keyId: string | null; +} + +export interface PluginVersionProvenance { + version: string; + origin: "bundled" | "marketplace" | "community" | "unknown"; + channel: "bundled" | "marketplace" | "disk" | "unknown"; + sourceId: string | null; + sourceName: string | null; + repository: string | null; + fileName: string | null; + archiveSha256: string | null; + contentDigest: string | null; + verification: PluginProvenanceVerification; +} + +export interface PluginDetailsModel { + identity: PluginDetailsIdentity; + displayName: string; + vendorName: string | null; + description: string | null; + localizedDescriptions: Record; + apiVersion: number; + enabled: boolean; + status: "accepted" | "rejected"; + rejection: DeclarativePluginRejection | null; + settings: DeclarativePluginSetting[]; + primaryLanguageId: string | null; + features: PluginDetailFeature[]; + permissions: string[]; + /** Complete Core install-review objects. Never replace these with command names. */ + programs: unknown[]; + versions: { + current: PluginVersionProvenance; + previous: PluginVersionProvenance | null; + updateStatus: "unknown"; + }; + operations: { + canSetEnabled: boolean; + canUninstall: boolean; + canRollback: boolean; + }; +} + +export type PluginDetailsOperation = "enable" | "disable" | "uninstall" | "rollback"; + +export type PluginDetailsState = + | { phase: "idle"; selection: null; workspacePath: string | null } + | { phase: "loading"; selection: PluginDetailsSelection; workspacePath: string | null } + | { + phase: "ready"; + selection: PluginDetailsSelection; + workspacePath: string | null; + details: PluginDetailsModel; + operation: PluginDetailsOperation | null; + error: string | null; + } + | { + phase: "failed"; + selection: PluginDetailsSelection; + workspacePath: string | null; + error: string; + }; + +export function samePluginSelection( + left: PluginDetailsSelection | null, + right: PluginDetailsSelection | null, +): boolean { + return left?.pluginId === right?.pluginId && left?.source === right?.source; +} diff --git a/windows/tauri/src/features/workspace/services/workspace-lifecycle.ts b/windows/tauri/src/features/workspace/services/workspace-lifecycle.ts index 7739b1454e..327a6efe49 100644 --- a/windows/tauri/src/features/workspace/services/workspace-lifecycle.ts +++ b/windows/tauri/src/features/workspace/services/workspace-lifecycle.ts @@ -490,6 +490,9 @@ async function closeWorkspaceRuntimeOnce( await declarativePluginProcessOwner.stop(workspaceId); const { invoke: invokePluginHost } = await import("@/platform/tauri-core"); await invokePluginHost("declarative_plugins_close_workspace_pages", { workspaceId }); + await invokePluginHost("declarative_plugins_build_close_workspace", { + workspaceRoot: path, + }).catch(() => {}); await dispose?.(path); // Defensive: nothing can reactivate a closing project, but never leave the registry diff --git a/windows/tauri/src/i18n/shared.generated.ts b/windows/tauri/src/i18n/shared.generated.ts index d8ff613b52..6ce3b12786 100644 --- a/windows/tauri/src/i18n/shared.generated.ts +++ b/windows/tauri/src/i18n/shared.generated.ts @@ -739,6 +739,22 @@ export const sharedCatalogs = { "aiShortcut.writeTests": "Write tests for a change", "aiShortcut.writeTestsContent": "Inspect the relevant behavior and add focused tests for this change:", "This feature is not available yet.": "This feature is not available yet.", + "buildSystems.close": "Close", + "buildSystems.detectFailed": "Build detection failed: {message}", + "buildSystems.emptyState": "No {name} project detected in this workspace.", + "buildSystems.fromModel": "from model", + "buildSystems.instanceSelector": "Instance", + "buildSystems.loading": "Detecting build systems…", + "buildSystems.manifestRejected": "{plugin}: {message}", + "buildSystems.modelError": "Model error: {message}", + "buildSystems.optionsTitle": "Options", + "buildSystems.projectsTitle": "Projects", + "buildSystems.railLabel": "{name} build window", + "buildSystems.refresh": "Reload", + "buildSystems.reset": "Reset", + "buildSystems.resetOption": "Reset {name}", + "buildSystems.shadowed": "{plugin} is shadowed by {by}", + "buildSystems.staleBanner": "The project model is out of date. Reload to pick up the changes.", "commandPalette.actions.ai-quick-edit.label": "AI: Quick Edit Selection", "commandPalette.actions.cli-install.label": "CLI: Install Terminal Command", "commandPalette.actions.color-theme.label": "Preferences: Color Theme", @@ -3976,6 +3992,67 @@ export const sharedCatalogs = { "settings.appearance.windowTransparencyDescription": "Use translucent app chrome and transparent native windows where supported", "settings.common.allFiles": "All Files", "settings.common.json": "JSON", + "settings.declarativePluginDetails.action.disable": "Disable", + "settings.declarativePluginDetails.action.enable": "Enable", + "settings.declarativePluginDetails.action.rollback": "Roll back…", + "settings.declarativePluginDetails.action.uninstall": "Uninstall", + "settings.declarativePluginDetails.availability.available": "Available on Windows", + "settings.declarativePluginDetails.availability.declaredOnly": "Declared, not currently hosted on Windows", + "settings.declarativePluginDetails.currentVersion": "Current version", + "settings.declarativePluginDetails.empty.features": "This plugin declares no features.", + "settings.declarativePluginDetails.empty.permissions": "This plugin requests no permissions.", + "settings.declarativePluginDetails.empty.programs": "This plugin starts no programs.", + "settings.declarativePluginDetails.empty.settings": "This plugin declares no settings.", + "settings.declarativePluginDetails.field.archiveDigest": "Archive SHA-256", + "settings.declarativePluginDetails.field.contentDigest": "Content SHA-256", + "settings.declarativePluginDetails.field.pluginId": "Plugin ID", + "settings.declarativePluginDetails.field.provides": "Provides", + "settings.declarativePluginDetails.field.repository": "Repository", + "settings.declarativePluginDetails.field.settings": "Settings", + "settings.declarativePluginDetails.field.source": "Source", + "settings.declarativePluginDetails.field.status": "Status", + "settings.declarativePluginDetails.field.update": "Updates", + "settings.declarativePluginDetails.field.verification": "Verification", + "settings.declarativePluginDetails.field.version": "Version", + "settings.declarativePluginDetails.loading": "Loading plugin details…", + "settings.declarativePluginDetails.noSelection": "Select a plugin to view its details.", + "settings.declarativePluginDetails.notice.community": "Community package. Its source is shown for context and does not by itself prove an official publisher.", + "settings.declarativePluginDetails.notice.diskCommunity": "Installed from disk. The publisher and repository are not authenticated; Lithe verifies the exact bytes you approved.", + "settings.declarativePluginDetails.permission.backend.launch": "Start the plugin's background service", + "settings.declarativePluginDetails.permission.credentials.store": "Store credentials through Lithe", + "settings.declarativePluginDetails.permission.languageServer.launch": "Start a language server", + "settings.declarativePluginDetails.permission.process.build": "Run build tools", + "settings.declarativePluginDetails.permission.process.run": "Run project programs", + "settings.declarativePluginDetails.permission.process.test": "Run project tests", + "settings.declarativePluginDetails.permission.process.vcs": "Run version-control tools", + "settings.declarativePluginDetails.permission.project.generate": "Generate project files", + "settings.declarativePluginDetails.permission.ui.contribute": "Add commands and tool windows", + "settings.declarativePluginDetails.permission.ui.web": "Show plugin-provided web content", + "settings.declarativePluginDetails.permissions": "Permissions", + "settings.declarativePluginDetails.previousVersion": "Previous version", + "settings.declarativePluginDetails.program": "Complete program declaration {index}", + "settings.declarativePluginDetails.programs": "Programs this plugin can start", + "settings.declarativePluginDetails.retry": "Retry", + "settings.declarativePluginDetails.settingsDisabled": "Enable this accepted plugin to edit its settings.", + "settings.declarativePluginDetails.source.bundled": "Bundled with Lithe", + "settings.declarativePluginDetails.source.disk": "Local disk package", + "settings.declarativePluginDetails.source.marketplace": "Marketplace", + "settings.declarativePluginDetails.source.unknown": "Unknown", + "settings.declarativePluginDetails.status.disabled": "Disabled", + "settings.declarativePluginDetails.status.enabled": "Enabled", + "settings.declarativePluginDetails.status.quarantined": "Quarantined", + "settings.declarativePluginDetails.tab.features": "Features", + "settings.declarativePluginDetails.tab.overview": "Overview", + "settings.declarativePluginDetails.tab.permissions": "Permissions & Programs", + "settings.declarativePluginDetails.tab.settings": "Settings", + "settings.declarativePluginDetails.tab.versions": "Versions & Source", + "settings.declarativePluginDetails.tabs": "Plugin detail sections", + "settings.declarativePluginDetails.update.unknown": "Marketplace update data is not connected yet.", + "settings.declarativePluginDetails.verification.approvedDigest": "Matches the bytes approved during installation", + "settings.declarativePluginDetails.verification.hostSeal": "Verified as part of the Lithe installation", + "settings.declarativePluginDetails.verification.publisherSignature": "Publisher signature verified", + "settings.declarativePluginDetails.verification.rejected": "Verification failed", + "settings.declarativePluginDetails.verification.unknown": "Not verified yet", "settings.declarativePlugins.closeWindow": "Close", "settings.declarativePlugins.confirmInstall": "Install", "settings.declarativePlugins.description": "Packages that declare processes; the host launches them after an install review. Themes and Worker extensions live in the Extensions browser.", @@ -4003,6 +4080,23 @@ export const sharedCatalogs = { "settings.declarativePlugins.showSettings": "Settings…", "settings.declarativePlugins.source.bundled": "bundled", "settings.declarativePlugins.source.installed": "installed", + "settings.declarativePlugins.sources.add": "Add source", + "settings.declarativePlugins.sources.addPlaceholder": "https://github.com/owner/repo or an index URL", + "settings.declarativePlugins.sources.corruptNotice": "The saved source list was unreadable. The original file was preserved next to the store and the list was rebuilt from the presets.", + "settings.declarativePlugins.sources.description": "Sources of the declarative plugin marketplace. Preset sources are rebuilt by the host; the official source can be disabled but not removed.", + "settings.declarativePlugins.sources.empty": "No third-party sources added.", + "settings.declarativePlugins.sources.errorAlreadyAdded": "That source has already been added.", + "settings.declarativePlugins.sources.errorGeneric": "The source could not be updated. Try again.", + "settings.declarativePlugins.sources.errorInvalidInput": "Enter an https URL of a plugin index or a GitHub repository.", + "settings.declarativePlugins.sources.errorLoad": "The source list could not be loaded.", + "settings.declarativePlugins.sources.errorNotFound": "That source no longer exists.", + "settings.declarativePlugins.sources.errorOfficialCannotBeRemoved": "The official source cannot be removed.", + "settings.declarativePlugins.sources.official": "Official", + "settings.declarativePlugins.sources.remove": "Remove", + "settings.declarativePlugins.sources.retry": "Retry", + "settings.declarativePlugins.sources.thirdParty": "Third-party", + "settings.declarativePlugins.sources.title": "Plugin sources", + "settings.declarativePlugins.sources.toggle": "Enable or disable source {name}", "settings.declarativePlugins.title": "Declarative plugins (contract v1)", "settings.declarativePlugins.toggle": "Enable or disable {name}", "settings.declarativePlugins.uninstall": "Uninstall", @@ -5674,6 +5768,22 @@ export const sharedCatalogs = { "aiShortcut.writeTests": "为变更编写测试", "aiShortcut.writeTestsContent": "查看相关行为,并为此变更添加聚焦测试:", "This feature is not available yet.": "待开发", + "buildSystems.close": "关闭", + "buildSystems.detectFailed": "构建识别失败:{message}", + "buildSystems.emptyState": "此工作区未检测到 {name} 工程。", + "buildSystems.fromModel": "来自模型", + "buildSystems.instanceSelector": "实例根", + "buildSystems.loading": "正在识别构建系统…", + "buildSystems.manifestRejected": "{plugin}:{message}", + "buildSystems.modelError": "模型错误:{message}", + "buildSystems.optionsTitle": "选项", + "buildSystems.projectsTitle": "工程", + "buildSystems.railLabel": "{name} 构建窗口", + "buildSystems.refresh": "重新加载", + "buildSystems.reset": "重置", + "buildSystems.resetOption": "重置 {name}", + "buildSystems.shadowed": "{plugin} 的构建系统由 {by} 接管", + "buildSystems.staleBanner": "工程模型已过期,重新加载以应用更改。", "commandPalette.actions.ai-quick-edit.label": "AI:快速编辑选区", "commandPalette.actions.cli-install.label": "CLI:安装终端命令", "commandPalette.actions.color-theme.label": "首选项:颜色主题", @@ -8911,6 +9021,67 @@ export const sharedCatalogs = { "settings.appearance.windowTransparencyDescription": "在支持时使用半透明应用框架和透明原生窗口", "settings.common.allFiles": "所有文件", "settings.common.json": "JSON", + "settings.declarativePluginDetails.action.disable": "停用", + "settings.declarativePluginDetails.action.enable": "启用", + "settings.declarativePluginDetails.action.rollback": "回滚…", + "settings.declarativePluginDetails.action.uninstall": "卸载", + "settings.declarativePluginDetails.availability.available": "Windows 当前可用", + "settings.declarativePluginDetails.availability.declaredOnly": "已声明,但 Windows 当前尚未托管", + "settings.declarativePluginDetails.currentVersion": "当前版本", + "settings.declarativePluginDetails.empty.features": "此插件未声明任何功能。", + "settings.declarativePluginDetails.empty.permissions": "此插件不请求任何权限。", + "settings.declarativePluginDetails.empty.programs": "此插件不会启动任何程序。", + "settings.declarativePluginDetails.empty.settings": "此插件未声明任何设置。", + "settings.declarativePluginDetails.field.archiveDigest": "归档 SHA-256", + "settings.declarativePluginDetails.field.contentDigest": "内容 SHA-256", + "settings.declarativePluginDetails.field.pluginId": "插件 ID", + "settings.declarativePluginDetails.field.provides": "提供", + "settings.declarativePluginDetails.field.repository": "仓库", + "settings.declarativePluginDetails.field.settings": "设置", + "settings.declarativePluginDetails.field.source": "来源", + "settings.declarativePluginDetails.field.status": "状态", + "settings.declarativePluginDetails.field.update": "更新", + "settings.declarativePluginDetails.field.verification": "验证", + "settings.declarativePluginDetails.field.version": "版本", + "settings.declarativePluginDetails.loading": "正在加载插件详情…", + "settings.declarativePluginDetails.noSelection": "选择一个插件以查看详情。", + "settings.declarativePluginDetails.notice.community": "这是社区插件。来源信息仅供参考,不能单独证明官方发布者身份。", + "settings.declarativePluginDetails.notice.diskCommunity": "此插件从磁盘安装,发布者与仓库未经认证;Lithe 会校验你批准过的确切字节。", + "settings.declarativePluginDetails.permission.backend.launch": "启动插件后台服务", + "settings.declarativePluginDetails.permission.credentials.store": "通过 Lithe 存储凭据", + "settings.declarativePluginDetails.permission.languageServer.launch": "启动语言服务器", + "settings.declarativePluginDetails.permission.process.build": "运行构建工具", + "settings.declarativePluginDetails.permission.process.run": "运行项目程序", + "settings.declarativePluginDetails.permission.process.test": "运行项目测试", + "settings.declarativePluginDetails.permission.process.vcs": "运行版本控制工具", + "settings.declarativePluginDetails.permission.project.generate": "生成项目文件", + "settings.declarativePluginDetails.permission.ui.contribute": "添加命令与工具窗口", + "settings.declarativePluginDetails.permission.ui.web": "显示插件提供的网页内容", + "settings.declarativePluginDetails.permissions": "权限", + "settings.declarativePluginDetails.previousVersion": "上一版本", + "settings.declarativePluginDetails.program": "完整程序声明 {index}", + "settings.declarativePluginDetails.programs": "此插件可以启动的程序", + "settings.declarativePluginDetails.retry": "重试", + "settings.declarativePluginDetails.settingsDisabled": "启用这个已接受的插件后才能编辑其设置。", + "settings.declarativePluginDetails.source.bundled": "随 Lithe 内置", + "settings.declarativePluginDetails.source.disk": "本地磁盘包", + "settings.declarativePluginDetails.source.marketplace": "插件市场", + "settings.declarativePluginDetails.source.unknown": "未知", + "settings.declarativePluginDetails.status.disabled": "已停用", + "settings.declarativePluginDetails.status.enabled": "已启用", + "settings.declarativePluginDetails.status.quarantined": "已隔离", + "settings.declarativePluginDetails.tab.features": "功能", + "settings.declarativePluginDetails.tab.overview": "概览", + "settings.declarativePluginDetails.tab.permissions": "权限与程序", + "settings.declarativePluginDetails.tab.settings": "设置", + "settings.declarativePluginDetails.tab.versions": "版本与来源", + "settings.declarativePluginDetails.tabs": "插件详情分区", + "settings.declarativePluginDetails.update.unknown": "插件市场更新数据尚未接入。", + "settings.declarativePluginDetails.verification.approvedDigest": "与安装时批准的字节一致", + "settings.declarativePluginDetails.verification.hostSeal": "已验证为 Lithe 安装的一部分", + "settings.declarativePluginDetails.verification.publisherSignature": "已验证发布者签名", + "settings.declarativePluginDetails.verification.rejected": "验证失败", + "settings.declarativePluginDetails.verification.unknown": "尚未验证", "settings.declarativePlugins.closeWindow": "关闭", "settings.declarativePlugins.confirmInstall": "安装", "settings.declarativePlugins.description": "以声明方式提供进程的插件包,宿主在安装审查后代为拉起。主题与 Worker 扩展请在扩展浏览器中管理。", @@ -8938,6 +9109,23 @@ export const sharedCatalogs = { "settings.declarativePlugins.showSettings": "设置…", "settings.declarativePlugins.source.bundled": "内置", "settings.declarativePlugins.source.installed": "已安装", + "settings.declarativePlugins.sources.add": "添加源", + "settings.declarativePlugins.sources.addPlaceholder": "https://github.com/owner/repo 或索引 URL", + "settings.declarativePlugins.sources.corruptNotice": "已保存的来源列表无法读取。原文件已保留在存储目录旁,列表已按预置来源重建。", + "settings.declarativePlugins.sources.description": "声明式插件市场的来源。预置来源由宿主重建;官方源可停用、不可删除。", + "settings.declarativePlugins.sources.empty": "没有已添加的第三方源。", + "settings.declarativePlugins.sources.errorAlreadyAdded": "该来源已添加。", + "settings.declarativePlugins.sources.errorGeneric": "来源操作失败,请重试。", + "settings.declarativePlugins.sources.errorInvalidInput": "请输入插件索引或 GitHub 仓库的 https 地址。", + "settings.declarativePlugins.sources.errorLoad": "来源列表加载失败。", + "settings.declarativePlugins.sources.errorNotFound": "该来源已不存在。", + "settings.declarativePlugins.sources.errorOfficialCannotBeRemoved": "官方来源不能删除。", + "settings.declarativePlugins.sources.official": "官方", + "settings.declarativePlugins.sources.remove": "删除", + "settings.declarativePlugins.sources.retry": "重试", + "settings.declarativePlugins.sources.thirdParty": "第三方", + "settings.declarativePlugins.sources.title": "插件源", + "settings.declarativePlugins.sources.toggle": "启用或停用源 {name}", "settings.declarativePlugins.title": "声明式插件(契约 v1)", "settings.declarativePlugins.toggle": "启用或停用 {name}", "settings.declarativePlugins.uninstall": "卸载", From 38171fbece5e5bfc059689309a9e24e2b3f7895f Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Mon, 26 Oct 2026 08:25:54 +0800 Subject: [PATCH 4/7] feat(windows): extend plugin build execution and model approvals --- .github/workflows/ci-windows.yml | 17 + docs/ci-builds.md | 4 + .../acceptance_driver.py | 263 ++++- .../acceptance_driver_test.py | 257 ++++- .../marketplace_acceptance.py | 548 ++++++++++ .../marketplace_acceptance_test.py | 348 +++++++ .../marketplace_fixture.py | 329 ++++++ scripts/reuse-worktree-resources.mjs | 2 + scripts/test-reuse-worktree-resources.mjs | 15 + .../test-windows-declarative-plugin-host.ps1 | 60 +- scripts/worktree-resources.json | 11 + shared/i18n/locales/en-US.json | 30 + shared/i18n/locales/zh-CN.json | 30 + shared/i18n/messages.json | 216 ++++ .../features/build-system-plugins.json | 11 +- .../features/plugin-marketplace.json | 8 +- windows/tauri/src-tauri/src/main.rs | 2 + .../tauri/src-tauri/src/plugin_host/build.rs | 94 ++ .../src-tauri/src/plugin_host/build/host.rs | 172 ++++ .../src/plugin_host/build/operation.rs | 107 +- .../src-tauri/src/plugin_host/build/output.rs | 171 +++ .../src/plugin_host/build/process.rs | 252 +++-- .../src-tauri/src/plugin_host/build/run.rs | 377 +++++++ .../src/plugin_host/build/run_tests.rs | 971 ++++++++++++++++++ .../src/plugin_host/build_registry.rs | 41 +- .../build-systems-service.test.ts | 281 +++++ .../build-systems/build-systems-service.ts | 118 ++- .../components/build-model-controls.test.tsx | 412 ++++++++ .../components/build-model-controls.tsx | 293 ++++++ .../components/build-system-pane.tsx | 33 +- windows/tauri/src/i18n/shared.generated.ts | 60 ++ 31 files changed, 5349 insertions(+), 184 deletions(-) create mode 100644 scripts/plugin-host-acceptance/marketplace_acceptance.py create mode 100644 scripts/plugin-host-acceptance/marketplace_acceptance_test.py create mode 100644 scripts/plugin-host-acceptance/marketplace_fixture.py create mode 100644 windows/tauri/src-tauri/src/plugin_host/build/output.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/build/run.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/build/run_tests.rs create mode 100644 windows/tauri/src/features/build-systems/components/build-model-controls.test.tsx create mode 100644 windows/tauri/src/features/build-systems/components/build-model-controls.tsx diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index a4588d5b55..ccaa725171 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -325,6 +325,7 @@ jobs: "src/features/plugins/declarative-plugins-section.test.tsx", "src/features/build-systems/build-systems-service.test.ts", "src/features/build-systems/build-systems-store.test.ts", + "src/features/build-systems/components/build-model-controls.test.tsx", "src/features/build-systems/components/build-system-pane.test.tsx", "src/features/build-systems/output/build-output-store.test.ts", "src/features/build-systems/output/build-output-path.test.ts", @@ -343,6 +344,22 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } + - name: Set up Python for plugin acceptance driver checks + uses: actions/setup-python@v6 + with: + python-version: '3.14' + + - name: Test plugin acceptance process ownership and marketplace fixtures + shell: pwsh + timeout-minutes: 4 + run: | + python -m pip install --disable-pip-version-check playwright==1.60.0 psutil==7.2.2 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -B scripts/plugin-host-acceptance/acceptance_driver_test.py --report .artifacts/test-stability/plugin-host-driver.json --dom-browser msedge + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -B scripts/plugin-host-acceptance/marketplace_acceptance_test.py --report .artifacts/test-stability/plugin-marketplace-driver.json --dom-browser msedge + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test Agent panel and capability gate shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-agent.json -- src/features/agent src/features/editor/stores/buffer-agent-conversation.test.ts src/utils/markdown-links.test.ts src/config/backend-capabilities.test.ts src/i18n src/features/file-system/utils/file-system-drop-controller.test.ts src/ui/icons.test.tsx diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 3ad81fa106..eccfc60827 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -724,6 +724,10 @@ Windows 的插件源配置、按源与平台缓存的原始索引、摘要绑定 Windows 命令模型的 `/plugin-state//build--.model.json` 按 `official-plugin-packages` 排除路由处理:由当前窗口、工作区代次和操作独占,确认后以 `create_new` 创建,进程树清理完成后删除;崩溃遗留由插件状态/卸载流程回收,不跨工作树复用。 +原生插件验收的 `.artifacts/plugin-host-acceptance/`(含市场 ZIP/索引、隔离运行镜像和报告)及 +`app.lithe.windows.host-acceptance` 用户配置走 `plugin-host-acceptance-state` 排除路由: +每次在目标工作树重新生成,无可复用 identity,不跨 worktree 复制;驱动回收监听器、进程和隔离用户数据, +报告留在本次验证目录。HTTPS 使用已有可信证书,不复制私钥或改系统信任,也不改发行资源及签名基线。 生成资源只有在构建流程写入可验证的源码、配置、平台、架构和工具链 identity stamp 后才能加入注册表,不能仅凭目录存在就跨 worktree 复制。 diff --git a/scripts/plugin-host-acceptance/acceptance_driver.py b/scripts/plugin-host-acceptance/acceptance_driver.py index 4e0bb4a583..c818f4b142 100644 --- a/scripts/plugin-host-acceptance/acceptance_driver.py +++ b/scripts/plugin-host-acceptance/acceptance_driver.py @@ -19,6 +19,7 @@ from ctypes import wintypes from pathlib import Path +import psutil from playwright.sync_api import Page, Playwright, expect, sync_playwright @@ -31,7 +32,30 @@ PROCESS_TERMINATE = 0x0001 SYNCHRONIZE = 0x00100000 WAIT_TIMEOUT = 258 +TH32CS_SNAPPROCESS = 0x00000002 +ERROR_NO_MORE_FILES = 18 +INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value + + +class ProcessEntry32(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), ("th32DefaultHeapID", ctypes.c_size_t), + ("th32ModuleID", wintypes.DWORD), ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), ("pcPriClassBase", wintypes.LONG), + ("dwFlags", wintypes.DWORD), ("szExeFile", wintypes.WCHAR * 260), + ] + + kernel32 = ctypes.windll.kernel32 +kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] +kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE +kernel32.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry32)] +kernel32.Process32FirstW.restype = wintypes.BOOL +kernel32.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry32)] +kernel32.Process32NextW.restype = wintypes.BOOL +kernel32.GetLastError.argtypes = [] +kernel32.GetLastError.restype = wintypes.DWORD kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] kernel32.OpenProcess.restype = wintypes.HANDLE kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] @@ -56,6 +80,9 @@ def __init__(self, pid: int, handle, creation_time: int, executable: str): self.handle = handle self.creation_time = creation_time self.executable = executable + # Keep kernel-object ownership from earlier successful inspections even + # if a later query fails or the root exits before cleanup. + self.descendants: list[OwnedProcess] = [] @classmethod def capture(cls, pid: int, expected: dict | None = None): @@ -74,13 +101,13 @@ def capture(cls, pid: int, expected: dict | None = None): if not kernel32.QueryFullProcessImageNameW(handle, 0, image, ctypes.byref(length)): return None if expected is not None: - # CIM serializes creation times at microsecond precision. - if creation_time // 10 != int(expected.get("CreationTime", -1)) // 10: + # Both sides retain native FILETIME ticks, without float loss. + if creation_time != int(expected.get("CreationTime", -1)): return None expected_image = expected.get("ExecutablePath") or "" if os.path.normcase(image.value) != os.path.normcase(expected_image): - # CIM can preserve a runtime-manager junction while the - # retained handle reports the executable's physical path. + # An inspector can preserve a runtime-manager junction + # while the handle reports the executable's physical path. try: if not os.path.samefile(image.value, expected_image): return None @@ -115,7 +142,7 @@ def close(self) -> None: def remember_owned_process(pid: int, expected: dict | None = None) -> OwnedProcess | None: previous = owned_processes.get(pid) if previous is not None and previous.running(): - if expected is None or previous.creation_time // 10 == int(expected.get("CreationTime", -1)) // 10: + if expected is None or previous.creation_time == int(expected.get("CreationTime", -1)): return previous return None process = OwnedProcess.capture(pid, expected) @@ -129,7 +156,7 @@ def remember_owned_process(pid: int, expected: dict | None = None) -> OwnedProce def owned_process_running(pid: int, expected: dict | None = None) -> bool: process = owned_processes.get(pid) return process is not None and process.running() and ( - expected is None or process.creation_time // 10 == int(expected.get("CreationTime", -1)) // 10 + expected is None or process.creation_time == int(expected.get("CreationTime", -1)) ) @@ -154,19 +181,35 @@ def terminate_process_tree(pid: int, timeout_seconds: float = 10, inspect_descen if process_exists(pid): raise RuntimeError(f"refusing to terminate PID {pid} without a retained ownership handle") return - if not owner.running(): - return - tree = capture_owned_tree(owner) if inspect_descendants else [] - targets = [owner] + [item for item in tree if item is not owner] + inspection_error = None + if inspect_descendants and owner.running(): + try: + capture_owned_tree(owner) + except Exception as error: # noqa: BLE001 - cleanup must still use retained handles. + inspection_error = error + targets = list(dict.fromkeys([owner] + (owner.descendants if inspect_descendants else []))) # Each termination targets the original kernel object, including if it # exited between inspection and this call. Never reopen a historical PID. + cleanup_errors = [] for process in targets: - process.terminate() + try: + process.terminate() + except Exception as error: # noqa: BLE001 - another owned handle still needs cleanup. + cleanup_errors.append(str(error)) deadline = time.monotonic() + timeout_seconds while any(process.running() for process in targets) and time.monotonic() < deadline: time.sleep(0.05) - if any(process.running() for process in targets): - raise RuntimeError(f"owned process tree rooted at {pid} survived cleanup") + survivors = [process.pid for process in targets if process.running()] + if survivors: + cleanup_errors.append(f"owned process handles survived cleanup: {survivors}") + if inspection_error is not None: + suffix = f"; {'; '.join(cleanup_errors)}" if cleanup_errors else "" + raise RuntimeError( + f"could not inspect the owned process tree rooted at {pid}: {inspection_error}; " + f"retained-handle cleanup attempted, uninspected descendants may remain{suffix}" + ) from inspection_error + if cleanup_errors: + raise RuntimeError("; ".join(cleanup_errors)) def run_bounded_process(command: list[str], timeout_seconds: float, *, inspect_descendants: bool = True) -> subprocess.CompletedProcess: @@ -175,6 +218,8 @@ def run_bounded_process(command: list[str], timeout_seconds: float, *, inspect_d stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", + errors="replace", creationflags=CREATE_NEW_PROCESS_GROUP, ) owner = remember_owned_process(process.pid) @@ -182,12 +227,25 @@ def run_bounded_process(command: list[str], timeout_seconds: float, *, inspect_d stdout, stderr = process.communicate(timeout=timeout_seconds) return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) except subprocess.TimeoutExpired as error: - terminate_process_tree(process.pid, inspect_descendants=inspect_descendants) + cleanup_error = None + try: + terminate_process_tree(process.pid, inspect_descendants=inspect_descendants) + except Exception as failure: # noqa: BLE001 - preserve cleanup failure in the timeout. + cleanup_error = failure + # Popen itself retains a kernel handle, including if capturing our + # stronger identity failed. Do not release that last cleanup owner early. + if process.poll() is None: + try: + process.kill() + process.wait(timeout=2) + except Exception as failure: # noqa: BLE001 - retain both cleanup diagnostics. + cleanup_error = RuntimeError(f"{cleanup_error or 'owned cleanup failed'}; Popen cleanup: {failure}") with contextlib.suppress(Exception): process.communicate(timeout=2) + suffix = f"; cleanup could not be confirmed: {cleanup_error}" if cleanup_error else "" raise TimeoutError( - f"process exceeded its {timeout_seconds:.1f}s local deadline: {command[0]}" - ) from error + f"process exceeded its {timeout_seconds:.1f}s local deadline: {command[0]}{suffix}" + ) from (cleanup_error or error) finally: if owner is not None: owner.close() @@ -227,26 +285,98 @@ def write_json(path: Path, value) -> None: path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") +def process_tree_ids(root_pid: int) -> list[int]: + """Read only topology; these PIDs never authorize termination by themselves.""" + snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if snapshot == INVALID_HANDLE_VALUE: + raise ctypes.WinError(kernel32.GetLastError()) + try: + entry = ProcessEntry32() + entry.dwSize = ctypes.sizeof(entry) + parents: dict[int, list[int]] = {} + present = kernel32.Process32FirstW(snapshot, ctypes.byref(entry)) + while present: + parents.setdefault(entry.th32ParentProcessID, []).append(entry.th32ProcessID) + present = kernel32.Process32NextW(snapshot, ctypes.byref(entry)) + error = kernel32.GetLastError() + if error != ERROR_NO_MORE_FILES: + raise ctypes.WinError(error) + finally: + kernel32.CloseHandle(snapshot) + pending, seen, result = [root_pid], set(), [] + while pending: + pid = pending.pop() + if pid in seen: + continue + seen.add(pid) + result.append(pid) + pending.extend(parents.get(pid, [])) + return result + + +def process_record(pid: int) -> dict | None: + """Use upstream native inspection, retaining exact Win32 identity ticks. + + psutil handles Windows command-line/parent inspection without WMI. Its + PID-reuse checks supplement, rather than replace, the retained kernel + handles used to authorize cleanup. Do not round FILETIME through a float. + """ + identity = None + try: + process = psutil.Process(pid) + process.create_time() # Freeze psutil's original process identity. + identity = OwnedProcess.capture(pid) + if identity is None: + if process.is_running(): + raise RuntimeError(f"could not inspect the process identity for {pid}") + return None + record = { + "ProcessId": pid, + "ParentProcessId": process.ppid(), + "Name": process.name(), + "ExecutablePath": identity.executable, + "CommandLine": subprocess.list2cmdline(process.cmdline()), + "CreationTime": identity.creation_time, + } + if not process.is_running() or not identity.running(): + return None + return record + except psutil.NoSuchProcess: + return None + finally: + if identity is not None: + identity.close() + + def process_tree(root_pid: int) -> list[dict]: - script = ( - "$items=Get-CimInstance Win32_Process | " - "Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine," - "@{Name='CreationTime';Expression={$_.CreationDate.ToUniversalTime().ToFileTimeUtc()}}; " - f"$pending=@({root_pid}); $seen=@{{}}; $out=@(); " - "while($pending.Count){$id=[int]$pending[0]; " - "$pending=@($pending | Select-Object -Skip 1); " - "if($seen.ContainsKey($id)){continue}; $seen[$id]=$true; " - "$match=@($items | Where-Object ProcessId -eq $id); $out += $match; " - "$pending += @($items | Where-Object ParentProcessId -eq $id | ForEach-Object ProcessId)}; " - "$out | ConvertTo-Json -Compress" - ) - result = run_bounded_process(["powershell", "-NoProfile", "-Command", script], 15, inspect_descendants=False) - if result.returncode != 0: - raise RuntimeError(result.stderr.strip() or "could not inspect the owned process tree") - if not result.stdout.strip(): - return [] - value = json.loads(result.stdout) - return value if isinstance(value, list) else [value] + return [record for pid in process_tree_ids(root_pid) + if (record := process_record(pid)) is not None] + + +def acceptance_process_inventory(executable: Path, identifier: str) -> list[dict]: + """Wrapper fallback: inspect only matching app/fixture candidates. + + Returned records are evidence, never termination authority. The wrapper + reopens and retains handles, then compares exact times and executable paths. + """ + expected = os.path.normcase(str(executable.resolve())) + state_root = Path(os.environ["LOCALAPPDATA"]) / identifier / "plugin-state" / PLUGIN_ID + roots = set() + for process in psutil.process_iter(["pid", "name"]): + name = (process.info["name"] or "").lower() + if name not in (executable.name.lower(), "node.exe", "node"): + continue + record = process_record(process.pid) + if record is None: + continue + if (os.path.normcase(record["ExecutablePath"]) == expected + or fixture_command_matches(record["CommandLine"], state_root)): + roots.add(process.pid) + records = {} + for root in roots: + for record in process_tree(root): + records[record["ProcessId"]] = record + return list(records.values()) def capture_owned_tree(root: OwnedProcess, records: list[dict] | None = None) -> list[OwnedProcess]: @@ -254,7 +384,7 @@ def capture_owned_tree(root: OwnedProcess, records: list[dict] | None = None) -> return [] records = process_tree(root.pid) if records is None else records root_record = next((item for item in records if int(item["ProcessId"]) == root.pid), None) - if root_record is None or int(root_record.get("CreationTime", -1)) // 10 != root.creation_time // 10: + if root_record is None or int(root_record.get("CreationTime", -1)) != root.creation_time: return [] pending = {root.pid: root} accepted = [] @@ -264,11 +394,13 @@ def capture_owned_tree(root: OwnedProcess, records: list[dict] | None = None) -> pid = int(record["ProcessId"]) if int(record["ParentProcessId"]) != parent_pid or pid == root.pid: continue - if int(record.get("CreationTime", -1)) // 10 < parent.creation_time // 10: + if int(record.get("CreationTime", -1)) < parent.creation_time: continue child = remember_owned_process(pid, record) if child is not None and child not in accepted: accepted.append(child) + if child not in root.descendants: + root.descendants.append(child) pending[pid] = child return accepted @@ -704,12 +836,17 @@ def open_fixture_file(page: Page, file_name: str = "acceptance.litheaccept") -> expect(root).to_have_attribute("aria-expanded", "true", timeout=15_000) existing = page.get_by_role("tab", name=re.compile(rf"^{re.escape(file_name)}")) if existing.count() > 0: - existing.first.hover() - existing.first.get_by_role("button").click() - expect(existing).to_have_count(0, timeout=15_000) + # A restored editor can still be attaching its language server. Select + # it without introducing an unrelated close/reopen transaction into + # the application-restart scenario under test. + existing.first.click() + expect(existing.first).to_have_attribute("aria-selected", "true", timeout=15_000) + return entry = page.get_by_text(file_name, exact=True) entry.first.wait_for(state="visible", timeout=30_000) entry.first.dblclick() + expect(page.get_by_role("tab", name=re.compile(rf"^{re.escape(file_name)}")).first).to_have_attribute( + "aria-selected", "true", timeout=15_000) def evidence_files(state_root: Path, prefix: str) -> list[Path]: @@ -832,7 +969,10 @@ def main() -> int: parser.add_argument("--package-v2", required=True, type=Path) parser.add_argument("--workspace-one", required=True, type=Path) parser.add_argument("--workspace-two", required=True, type=Path) + from marketplace_acceptance import add_arguments, validate_arguments + add_arguments(parser) args = parser.parse_args() + validate_arguments(parser, args) artifact_root = args.artifact_root.resolve() artifact_root.mkdir(parents=True, exist_ok=True) @@ -845,10 +985,11 @@ def main() -> int: "macOS native acceptance", "This driver is intentionally Windows/WebView2-only; macOS requires its own product acceptance lane.", ) - reporter.document_pending( - "roadmap M2 marketplace package acceptance", - "The public indexes contain no downloadable Windows package, so a real marketplace install cannot yet be claimed.", - ) + if not args.marketplace: + reporter.document_pending( + "roadmap M2 marketplace package acceptance", + "The optional real HTTPS marketplace lane was not requested; use --marketplace. Native package transactions additionally need the controlled fixture and an already-trusted TLS endpoint.", + ) reporter.document_pending( "roadmap M4 build-system provider acceptance", "The Windows host does not yet advertise buildSystems/buildProviders; this lane must remain pending instead of treating API rejection as a product failure.", @@ -1057,7 +1198,9 @@ def main() -> int: with reporter.step("uninstall removes package and state"): live = set() if lsp_started: - open_fixture_file(page) + # Exercise uninstall with a genuinely new document after + # rollback, independently of restored-editor reactivation. + open_fixture_file(page, "uninstall.litheaccept") live = wait_for_server_count(state_root, 2) all_owned_fixture_pids.update(live) open_plugin_settings(page) @@ -1075,6 +1218,33 @@ def main() -> int: assert not (args.app_local_data / "plugins" / PLUGIN_ID).exists() assert not (args.app_local_data / "plugin-state" / PLUGIN_ID).exists() + if args.marketplace: + from marketplace_acceptance import MarketplaceAcceptance + + def restart_marketplace(): + nonlocal session + cleanup_evidence.append(session.stop(require_graceful=True)) + session = None + wait_pids_dead(all_owned_fixture_pids, "fixture processes before marketplace restart") + sequence = len(cleanup_evidence) + 1 + session = AppSession( + playwright, args.executable.resolve(), args.workspace_one.resolve(), + args.webview_data.resolve(), artifact_root / "logs", sequence, + ) + current = session.start() + finish_onboarding(current) + return current + + # The disk lane deliberately opened two workspaces. Start the + # marketplace with one window so restoring an old second editor + # cannot introduce an extra language-server owner into its cases. + with reporter.step("marketplace isolated native session"): + page = restart_marketplace() + MarketplaceAcceptance( + sys.modules[__name__], args, reporter, page, + restart_marketplace, all_owned_fixture_pids, + ).run() + with reporter.step("owned application and helper processes cleanly terminate"): cleanup_evidence.append(session.stop(require_graceful=True)) session = None @@ -1192,6 +1362,9 @@ def main() -> int: "maxMs": 120000, } ) + if args.marketplace: + from marketplace_acceptance import record_unreached_cases + record_unreached_cases(reporter) write_json( args.report, { diff --git a/scripts/plugin-host-acceptance/acceptance_driver_test.py b/scripts/plugin-host-acceptance/acceptance_driver_test.py index 96880555c9..20e6f41a90 100644 --- a/scripts/plugin-host-acceptance/acceptance_driver_test.py +++ b/scripts/plugin-host-acceptance/acceptance_driver_test.py @@ -4,13 +4,16 @@ from __future__ import annotations import argparse +import ctypes import json import subprocess import sys import tempfile import time from pathlib import Path +from unittest.mock import MagicMock, patch +import acceptance_driver as driver from acceptance_driver import ( DISPLAY_NAME, PLUGIN_ID, OwnedProcess, fixture_command_matches, plugin_row, process_exists, run_bounded_process, @@ -28,18 +31,25 @@ def run_case(name, action, tests, max_ms=10_000): except Exception as error: # noqa: BLE001 - the report must retain any failure. status = "failed" details = f"{type(error).__name__}: {error}" + duration_ms = round((time.monotonic() - started) * 1000, 3) + if duration_ms >= max_ms: + status = "failed" + details = f"{details}; " if details else "" + details += f"test exceeded its {max_ms}ms budget ({duration_ms}ms)" tests.append( { "name": name, "suite": "plugin-host-acceptance / driver", "status": status, - "durationMs": round((time.monotonic() - started) * 1000, 3), + "durationMs": duration_ms, "details": details, "maxMs": max_ms, } ) if status != "passed": - raise AssertionError(details) + # Retain the failure and nonzero suite exit while still checking the + # remaining cleanup/identity/DOM regressions on an unhealthy machine. + print(f"FAIL {name}: {details}", file=sys.stderr) def grouped_plugin_dom(browser_channel: str) -> None: @@ -92,6 +102,16 @@ def grouped_plugin_dom(browser_channel: str) -> None: expect(page.locator("[data-plugin-source='bundled']")).to_have_count(1) expect(page.locator("[data-plugin-id='io.example.other']")).to_have_count(1) assert page.locator("body").get_attribute("data-trap") is None + page.set_content(''' +
    workspace
    +
    + acceptance.litheaccept + +
    + ''', timeout=2_000) + driver.open_fixture_file(page) + expect(page.get_by_role("tab")).to_have_attribute("aria-selected", "true") + assert page.locator("body").get_attribute("data-closed") is None finally: browser.close() @@ -118,6 +138,177 @@ def completes(): assert result.returncode == 0 assert result.stdout.strip() == "bounded-ok" + def output_is_utf8_and_loss_tolerant(): + result = run_bounded_process([ + sys.executable, "-c", + "import sys; " + "sys.stdout.buffer.write('进程输出'.encode('utf-8') + b'\\xff'); " + "sys.stderr.buffer.write('查询错误'.encode('utf-8') + b'\\xfe'); " + "sys.exit(1)", + ], timeout_seconds=5) + assert result.returncode == 1 + assert result.stdout == "进程输出\ufffd", repr(result.stdout) + assert result.stderr == "查询错误\ufffd", repr(result.stderr) + + def inspection_failure_is_not_an_empty_tree(): + with patch.object(driver, "process_tree_ids", return_value=[123]): + with patch.object(driver.psutil, "Process", side_effect=driver.psutil.AccessDenied(123)): + try: + driver.process_tree(123) + except driver.psutil.AccessDenied: + pass + else: + raise AssertionError("inspection failure was accepted as an empty tree") + + def metadata_rejects_pid_reuse_and_closes_identity(): + process, identity = MagicMock(), MagicMock() + process.is_running.return_value = False + identity.creation_time = 134000000000000001 + identity.executable = "C:/fixture/node.exe" + with patch.object(driver.psutil, "Process", return_value=process): + with patch.object(driver.OwnedProcess, "capture", return_value=identity): + assert driver.process_record(123) is None + identity.close.assert_called_once() + process.is_running.return_value = True + identity.running.return_value = True + process.ppid.return_value = 100 + process.name.return_value = "node.exe" + process.cmdline.return_value = ["node.exe", "中文 fixture.mjs"] + identity.reset_mock() + with patch.object(driver.psutil, "Process", return_value=process): + with patch.object(driver.OwnedProcess, "capture", return_value=identity): + record = driver.process_record(123) + assert record["CreationTime"] == 134000000000000001 + assert record["ParentProcessId"] == 100 + assert record["CommandLine"] == 'node.exe "中文 fixture.mjs"' + identity.close.assert_called_once() + + def topology_snapshot_closes_handles_and_rejects_errors(): + entries = iter([(101, 0), (102, 101), (103, 102), (999, 0)]) + + def next_entry(_handle, pointer): + try: + pid, parent_pid = next(entries) + except StopIteration: + return False + entry = ctypes.cast(pointer, ctypes.POINTER(driver.ProcessEntry32)).contents + assert entry.dwSize == ctypes.sizeof(driver.ProcessEntry32) + entry.th32ProcessID, entry.th32ParentProcessID = pid, parent_pid + return True + + with patch.object(driver.kernel32, "CreateToolhelp32Snapshot", return_value=456): + with patch.object(driver.kernel32, "Process32FirstW", side_effect=next_entry): + with patch.object(driver.kernel32, "Process32NextW", side_effect=next_entry): + with patch.object(driver.kernel32, "GetLastError", return_value=driver.ERROR_NO_MORE_FILES): + with patch.object(driver.kernel32, "CloseHandle") as close: + assert driver.process_tree_ids(101) == [101, 102, 103] + close.assert_called_once_with(456) + with patch.object(driver.kernel32, "Process32FirstW", return_value=False): + with patch.object(driver.kernel32, "GetLastError", return_value=5): + with patch.object(driver.kernel32, "CloseHandle") as close: + try: + driver.process_tree_ids(101) + except OSError as error: + assert error.winerror == 5 + else: + raise AssertionError("failed topology snapshot was accepted as an empty tree") + close.assert_called_once_with(456) + with patch.object(driver.kernel32, "CreateToolhelp32Snapshot", return_value=driver.INVALID_HANDLE_VALUE): + with patch.object(driver.kernel32, "GetLastError", return_value=5): + with patch.object(driver.kernel32, "CloseHandle") as close: + try: + driver.process_tree_ids(101) + except OSError as error: + assert error.winerror == 5 + else: + raise AssertionError("invalid snapshot handle was accepted") + close.assert_not_called() + + def wrapper_inventory_rejects_unrelated_lookalikes(): + executable = Path("C:/fixture/lithe-host-acceptance.exe") + state = Path(driver.os.environ["LOCALAPPDATA"]) / driver.ACCEPTANCE_IDENTIFIER / "plugin-state" / PLUGIN_ID + candidates = [] + records = {} + for pid, directory in ((101, state / "workspace"), (102, Path(str(state) + "-unrelated"))): + process = MagicMock(pid=pid) + process.info = {"pid": pid, "name": "node.exe"} + candidates.append(process) + records[pid] = {"ProcessId": pid, "ExecutablePath": "C:/runtime/node.exe", + "CommandLine": subprocess.list2cmdline([ + "node.exe", "C:/fixture/fake-lsp-server.mjs", "--state-dir", str(directory), + ])} + with patch.object(driver.psutil, "process_iter", return_value=candidates): + with patch.object(driver, "process_record", side_effect=records.get): + with patch.object(driver, "process_tree", side_effect=lambda pid: [records[pid]]) as tree: + assert driver.acceptance_process_inventory(executable, driver.ACCEPTANCE_IDENTIFIER) == [records[101]] + tree.assert_called_once_with(101) + def query_failure_still_cleans_retained_handles(): + class HandleOwner: + def __init__(self, pid): + self.pid = pid + self.alive = True + self.descendants = [] + self.terminated = False + self.creation_time = pid * 100 + + def running(self): + return self.alive + + def terminate(self): + self.terminated = True + self.alive = False + + root, child, unrelated = (HandleOwner(pid) for pid in (101, 102, 103)) + records = [ + {"ProcessId": root.pid, "ParentProcessId": 0, "CreationTime": root.creation_time}, + {"ProcessId": child.pid, "ParentProcessId": root.pid, "CreationTime": child.creation_time}, + ] + with patch.object(driver, "remember_owned_process", return_value=child): + assert driver.capture_owned_tree(root, records) == [child] + query_error = RuntimeError("CIM 查询失败") + with patch.object(driver, "owned_processes", {item.pid: item for item in (root, child, unrelated)}): + with patch.object(driver, "process_tree", side_effect=query_error): + try: + driver.terminate_process_tree(root.pid, timeout_seconds=0) + except RuntimeError as error: + assert error.__cause__ is query_error + assert "CIM 查询失败" in str(error) + assert "uninspected descendants may remain" in str(error) + else: + raise AssertionError("failed inspection reported complete tree cleanup") + assert root.terminated and child.terminated + assert unrelated.alive and not unrelated.terminated + + def timeout_query_failure_keeps_error_and_reaps_root(): + real_popen = driver.subprocess.Popen + spawned = [] + + def capture_popen(*positional, **keyword): + process = real_popen(*positional, **keyword) + spawned.append(process) + return process + + try: + with patch.object(driver.subprocess, "Popen", side_effect=capture_popen): + with patch.object(driver, "process_tree", side_effect=RuntimeError("CIM unavailable")): + try: + run_bounded_process([ + sys.executable, "-c", "import time; time.sleep(60)", + ], timeout_seconds=0.05) + except TimeoutError as error: + assert "CIM unavailable" in str(error), str(error) + assert "uninspected descendants may remain" in str(error), str(error) + else: + raise AssertionError("bounded process did not time out") + assert len(spawned) == 1 + assert spawned[0].poll() is not None, "retained root survived failed inspection" + assert spawned[0].pid not in driver.owned_processes, "root ownership was not released after cleanup" + finally: + for process in spawned: + if process.poll() is None: + process.kill() + process.wait(timeout=3) + def timeout_cleans_tree(): args.report.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory( @@ -125,26 +316,47 @@ def timeout_cleans_tree(): ) as directory: pid_file = Path(directory) / "child.pid" script = ( - "import pathlib,subprocess,sys,time; " + "import json,pathlib,subprocess,sys,time; " + f"sys.path.insert(0,{str(Path(__file__).resolve().parent)!r}); " + "from acceptance_driver import OwnedProcess; " "p=subprocess.Popen([sys.executable,'-c','import time; time.sleep(60)']); " - f"pathlib.Path({str(pid_file)!r}).write_text(str(p.pid)); " + "owner=OwnedProcess.capture(p.pid); assert owner is not None; " + f"pathlib.Path({str(pid_file)!r}).write_text(json.dumps(" + "{'pid':p.pid,'CreationTime':owner.creation_time,'ExecutablePath':owner.executable}),encoding='utf-8'); " "time.sleep(60)" ) + child_owner = None try: - run_bounded_process([sys.executable, "-c", script], timeout_seconds=1) - except TimeoutError: - pass - else: - raise AssertionError("bounded process did not time out") - deadline = time.monotonic() + 5 - while not pid_file.exists() and time.monotonic() < deadline: - time.sleep(0.02) - assert pid_file.exists(), "fixture child PID was not recorded" - child_pid = int(pid_file.read_text(encoding="utf-8")) - deadline = time.monotonic() + 5 - while process_exists(child_pid) and time.monotonic() < deadline: - time.sleep(0.05) - assert not process_exists(child_pid), f"timed-out child {child_pid} survived cleanup" + try: + run_bounded_process([sys.executable, "-B", "-c", script], timeout_seconds=1) + except TimeoutError as error: + timeout_error = str(error) + else: + raise AssertionError("bounded process did not time out") + deadline = time.monotonic() + 5 + while not pid_file.exists() and time.monotonic() < deadline: + time.sleep(0.02) + assert pid_file.exists(), "fixture child PID was not recorded" + identity = json.loads(pid_file.read_text(encoding="utf-8")) + child_pid = identity["pid"] + # Keep teardown ownership separate from the driver's cached + # tree, so it cannot make the behavior under test succeed. + child_owner = OwnedProcess.capture(child_pid, identity) + deadline = time.monotonic() + 5 + while process_exists(child_pid) and time.monotonic() < deadline: + time.sleep(0.05) + assert not process_exists(child_pid), f"timed-out child {child_pid} survived cleanup: {timeout_error}" + assert "cleanup could not be confirmed" not in timeout_error, timeout_error + finally: + if child_owner is None and pid_file.exists(): + identity = json.loads(pid_file.read_text(encoding="utf-8")) + child_owner = OwnedProcess.capture(identity["pid"], identity) + if child_owner is not None: + try: + child_owner.terminate() + driver.wait_until(lambda: not child_owner.running(), 3, "the fixture child's final cleanup", interval=0.02) + finally: + child_owner.close() def retained_handle_ignores_changed_pid(): first = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) @@ -233,6 +445,13 @@ def grouped_plugin_actions(): try: run_case("bounded process captures a successful child", completes, tests) + run_case("process output decodes UTF-8 and replaces malformed bytes", output_is_utf8_and_loss_tolerant, tests) + run_case("inspection errors cannot become an empty process tree", inspection_failure_is_not_an_empty_tree, tests) + run_case("metadata rejects PID reuse and preserves exact native identity", metadata_rejects_pid_reuse_and_closes_identity, tests) + run_case("topology snapshots close their handles and reject inspection errors", topology_snapshot_closes_handles_and_rejects_errors, tests) + run_case("wrapper inventory rejects unrelated fixture path lookalikes", wrapper_inventory_rejects_unrelated_lookalikes, tests) + run_case("query failure cleans retained handles without touching unrelated processes", query_failure_still_cleans_retained_handles, tests) + run_case("timeout preserves query failure and reaps its retained root", timeout_query_failure_keeps_error_and_reaps_root, tests) run_case("timeout terminates the owned process tree", timeout_cleans_tree, tests) run_case("termination targets a retained handle despite a changed PID", retained_handle_ignores_changed_pid, tests) run_case("creation-time and executable mismatches reject ownership", mismatched_identity_is_not_owned, tests) @@ -264,7 +483,7 @@ def grouped_plugin_actions(): + "\n", encoding="utf-8", ) - return 0 + return 1 if any(test["status"] == "failed" for test in tests) else 0 if __name__ == "__main__": diff --git a/scripts/plugin-host-acceptance/marketplace_acceptance.py b/scripts/plugin-host-acceptance/marketplace_acceptance.py new file mode 100644 index 0000000000..c7ef66d516 --- /dev/null +++ b/scripts/plugin-host-acceptance/marketplace_acceptance.py @@ -0,0 +1,548 @@ +"""Optional real-marketplace lane for acceptance_driver; no mocked native answers. + +Public-source browsing is usable without a controlled publisher. The transaction +lane additionally needs the generated fixture and a certificate already trusted +for its reachable HTTPS hostname. Host IPC checks are named separately from UI +actions; lightweight HTML locator checks are never reported as native execution. +""" + +from __future__ import annotations + +import json +import re +import uuid +from pathlib import Path + +from playwright.sync_api import Page, expect + +from marketplace_fixture import FixtureCatalog, FixtureServer, PUBLIC_SOURCES + + +PUBLIC_CASES = ( + "marketplace public HTTPS refresh and search", + "marketplace public source add, disable, remove, and official protection", +) +LATE_UI_CASE = "marketplace native UI cancels late prepare on cancel, scope switch, and page close" +CONTROLLED_CASES = ( + "marketplace controlled source management and same-ID offers", + "marketplace host rejects missing approval and a wrong review digest", + "marketplace UI cancel and source switch discard prepared reviews", + "marketplace late prepare is cancelled by source disable and removal", + "marketplace UI shows complete permissions and programs before install", + "marketplace installation and source provenance survive restart", + "marketplace competing source cannot take over an installed ID", + "marketplace rejects archive digest and untrusted publisher signatures", + "marketplace source refresh invalidates an earlier host review", + "marketplace disabled installation updates only from its original source", + "marketplace disable and uninstall revoke processes and stale reviews", + LATE_UI_CASE, +) +SIGNATURE_GAP = "marketplace official publisher-signed Windows package" + + +def add_arguments(parser) -> None: + parser.add_argument("--marketplace", action="store_true", + help="Also exercise real HTTPS marketplace UI/IPC after the disk/LSP lane.") + parser.add_argument("--marketplace-fixture-root", type=Path, + help="Generated marketplace_fixture.py directory; never an installed app directory.") + parser.add_argument("--marketplace-tls-cert", type=Path, + help="Existing certificate chain valid and already trusted for the plan's HTTPS hostname.") + parser.add_argument("--marketplace-tls-key", type=Path, + help="Existing TLS key file; its contents are never copied into artifacts.") + parser.add_argument("--marketplace-listen-host", default="127.0.0.1") + + +def validate_arguments(parser, args) -> None: + supplied = (args.marketplace_fixture_root, args.marketplace_tls_cert, args.marketplace_tls_key) + if any(supplied) and (not args.marketplace or not all(supplied)): + parser.error("controlled marketplace execution requires --marketplace, --marketplace-fixture-root, --marketplace-tls-cert and --marketplace-tls-key together") + + +def settings(page: Page): + return page.locator("[data-settings-content='declarative-plugins']") + + +def marketplace(page: Page): + return settings(page).get_by_test_id("marketplace-page") + + +def source_row(page: Page, source_id: str): + return settings(page).get_by_test_id(f"declarative-marketplace-source-{source_id}").locator("xpath=ancestor::li[1]") + + +def offer_row(page: Page, plugin_id: str): + return marketplace(page).get_by_test_id(f"marketplace-plugin-{plugin_id}") + + +def choose_source(page: Page, source_id: str) -> None: + select = marketplace(page).get_by_test_id("marketplace-source-select") + expect(select).to_be_enabled(timeout=15_000) + select.select_option(source_id, timeout=15_000) + expect(select).to_have_value(source_id, timeout=15_000) + + +def native_call(page: Page, command: str, arguments: dict | None = None): + # Fetches have the production host's 60s deadline. The observer allows + # another 10s for cancellation/publication without weakening that deadline. + timeout_ms = 70_000 if command.endswith(("_refresh", "_prepare")) else 10_000 + result = page.evaluate("""async ([command, args, timeout]) => { + let timer; + try { + const value = await Promise.race([ + window.__TAURI_INTERNALS__.invoke(command, args), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`acceptance IPC deadline: ${command}`)), timeout); + }), + ]); + return {ok: true, value}; + } catch (error) { return {ok: false, error: String(error)}; } + finally { clearTimeout(timer); } + }""", [command, arguments or {}, timeout_ms]) + if not result["ok"]: + raise RuntimeError(result["error"]) + return result["value"] + + +def expect_rejected(page: Page, command: str, arguments: dict, pattern: str) -> str: + try: + native_call(page, command, arguments) + except RuntimeError as error: + assert re.search(pattern, str(error), re.IGNORECASE), str(error) + return str(error) + raise AssertionError(f"the real host unexpectedly accepted {command}") + + +def read_review_programs(page: Page, *, marker: str | None = None) -> list[dict]: + card = marketplace(page).get_by_test_id("marketplace-review-card") + expect(card).to_be_visible(timeout=75_000) + expect(card.get_by_test_id("marketplace-unverified-notice")).to_be_visible() + expect(card.get_by_test_id("marketplace-official-badge")).to_have_count(0) + assert card.locator("ul li").all_text_contents() == ["languageServer.launch"] + declaration = card.locator("pre") + if not declaration.is_visible(): + card.get_by_test_id("marketplace-review-programs-toggle").click() + expect(declaration).to_be_visible(timeout=5_000) + programs = json.loads(declaration.inner_text(timeout=5_000)) + if marker is not None: + # This is the fixture's expected public review contract, including the + # discovered runtime, every argument and the empty validation probe. + expected = [ + {"kind": "runtime", "tool": "node"}, + {"kind": "runtimeLanguageServer", "tool": "node", "packagedRuntime": None, + "entry": "server/fake-lsp-server.mjs", "runtimeArguments": [], + "arguments": ["--stdio", "--state-dir", "${workspaceStateDir}", + "--fixture-version", marker], "validationArguments": [], + "languageID": "litheaccept"}, + ] + canonical = lambda entries: sorted(json.dumps(item, sort_keys=True) for item in entries) + assert canonical(programs) == canonical(expected), programs + return programs + + +class MarketplaceAcceptance: + def __init__(self, driver, args, reporter, page: Page, restart, owned_fixture_pids: set[int]): + self.driver = driver + self.args = args + self.reporter = reporter + self.page = page + self.restart = restart + self.pids = owned_fixture_pids + self.evidence = args.artifact_root.resolve() / "marketplace" + self.evidence.mkdir(parents=True, exist_ok=True) + self.operations: set[str] = set() + + def sources(self): + return native_call(self.page, "declarative_marketplace_sources_state")["sources"] + + def indexes(self): + return native_call(self.page, "declarative_marketplace_list") + + def source(self, url: str): + return next((item for item in self.sources() if item["indexURL"].lower() == url.lower()), None) + + def add_source(self, url: str): + form = settings(self.page).get_by_test_id("declarative-marketplace-sources") + form.locator("input[type='text']").fill(url) + form.get_by_role("button", name="Add source", exact=True).click() + source = self.driver.wait_until(lambda: self.source(url), 15, "the UI-added native source") + expect(source_row(self.page, source["id"])).to_be_visible(timeout=15_000) + return source + + def enable_source(self, source_id: str, enabled: bool): + toggle = source_row(self.page, source_id).get_by_role("switch") + expect(toggle).to_be_enabled(timeout=15_000) + if toggle.get_attribute("aria-checked") != str(enabled).lower(): + toggle.click() + expect(toggle).to_have_attribute("aria-checked", str(enabled).lower(), timeout=15_000) + self.driver.wait_until(lambda: any(item["id"] == source_id and item["isEnabled"] is enabled + for item in self.sources()), 15, "native source enabled state") + + def remove_source(self, source_id: str): + row = source_row(self.page, source_id) + row.get_by_role("button", name="Remove", exact=True).click(timeout=15_000) + expect(row).to_have_count(0, timeout=15_000) + assert all(item["id"] != source_id for item in self.sources()) + + def refresh(self, source_id: str, *, digest: str | None = None): + choose_source(self.page, source_id) + button = marketplace(self.page).get_by_test_id("marketplace-refresh-button") + expect(button).to_be_enabled(timeout=15_000) + button.click() + def ready(): + item = next((item for item in self.indexes() if item["sourceId"] == source_id), None) + if not item or item["index"] is None: + return None + if digest is not None and not any(plugin.get("availability", {}).get("sha256") == digest + for plugin in item["index"]["plugins"]): + return None + return item["index"] + index = self.driver.wait_until(ready, 75, "Core-validated HTTPS source cache") + expect(button).to_have_text("Refresh", timeout=75_000) + expect(marketplace(self.page).get_by_test_id(f"marketplace-source-status-{source_id}") + .get_by_role("alert")).to_have_count(0) + self.driver.write_json(self.evidence / f"index-{source_id}.json", index) + return index + + def search(self, index: dict): + field = marketplace(self.page).get_by_test_id("marketplace-search-input") + if index["plugins"]: + first = index["plugins"][0] + field.fill(first["name"]) + expect(offer_row(self.page, first["id"])).to_be_visible() + field.fill("lithe-acceptance-no-match-" + uuid.uuid4().hex) + expect(marketplace(self.page).get_by_test_id("marketplace-empty")).to_be_visible() + field.fill("") + + def operation(self) -> str: + value = "acceptance-" + uuid.uuid4().hex + self.operations.add(value) + return value + + def prepare(self, source_id: str): + operation = self.operation() + return native_call(self.page, "declarative_marketplace_prepare", { + "operationId": operation, "sourceId": source_id, "pluginId": self.driver.PLUGIN_ID, + }) + + def cancel(self, operation: str): + native_call(self.page, "declarative_marketplace_cancel", {"operationId": operation}) + self.operations.discard(operation) + + def no_snapshots(self): + root = self.args.app_local_data / "plugin-temp" + self.driver.wait_until(lambda: not root.exists() or not any(root.iterdir()), + 15, "cancelled marketplace downloads and snapshots") + + def no_install(self): + assert not (self.args.app_local_data / "plugins" / self.driver.PLUGIN_ID).exists() + self.no_snapshots() + + def ui_prepare(self, source_id: str): + choose_source(self.page, source_id) + row = offer_row(self.page, self.driver.PLUGIN_ID) + row.get_by_test_id(f"marketplace-install-{self.driver.PLUGIN_ID}").click(timeout=15_000) + card = marketplace(self.page).get_by_test_id("marketplace-review-card") + result = marketplace(self.page).locator( + "[data-testid='marketplace-review-card'], " + f"[data-testid='marketplace-plugin-{self.driver.PLUGIN_ID}'] [role='alert']" + ) + result.first.wait_for(state="visible", timeout=75_000) + assert card.is_visible(), row.inner_text(timeout=5_000) + return card + + def assert_record(self, source: dict, asset: dict, programs: list[dict] | None = None): + record = self.driver.installation_record(self.args.app_local_data) + market = record["marketplace"] + assert record["schemaVersion"] == 2 and record["origin"] == "community" + assert record.get("disk") is None + assert record["version"] == asset["version"] + assert market["sourceId"] == source["id"] and market["indexUrl"] == source["indexURL"] + assert market["archiveSha256"] == asset["sha256"] + assert market["archiveUrl"].endswith("/" + asset["file"]) + assert re.fullmatch(r"[0-9a-f]{64}", market["contentDigest"]) + if programs is not None: + approved = [json.loads(program) for program in market["approvedPrograms"]] + assert sorted(json.dumps(p, sort_keys=True) for p in approved) == sorted( + json.dumps(p, sort_keys=True) for p in programs) + assert self.driver.active_fixture_marker(self.args.app_local_data, asset["version"]) == asset["marker"] + self.driver.write_json(self.evidence / f"installation-{asset['version']}.json", record) + return record + + def confirm_rejected(self, review: dict, pattern: str, *, approved=True, digest=None): + try: + return expect_rejected(self.page, "declarative_marketplace_confirm", { + "operationId": review["operationId"], "approved": approved, + "manifestDigest": review["prepared"]["manifestDigest"] if digest is None else digest, + }, pattern) + finally: + self.cancel(review["operationId"]) + + def run_public(self): + self.driver.open_plugin_settings(self.page) + with self.reporter.step(PUBLIC_CASES[0]): + current = self.sources() + public_indexes = [] + for url in PUBLIC_SOURCES: + source = next((item for item in current if item["indexURL"] == url), None) + assert source is not None, f"the public preset is missing: {url}" + self.enable_source(source["id"], True) + index = self.refresh(source["id"]) + self.search(index) + public_indexes.append({"source": source, "index": index}) + for plugin in index["plugins"]: + row = offer_row(self.page, plugin["id"]) + expect(row).to_be_visible() + if plugin["availability"]["status"] != "available": + expect(row.get_by_test_id(f"marketplace-install-{plugin['id']}")).to_have_count(0) + self.driver.write_json(self.evidence / "public-catalogs.json", public_indexes) + with self.reporter.step(PUBLIC_CASES[1]): + official = next(item for item in self.sources() if item["isOfficial"]) + expect(source_row(self.page, official["id"]).get_by_role("button", name="Remove", exact=True)).to_have_count(0) + expect_rejected(self.page, "declarative_marketplace_sources_remove", { + "sourceId": official["id"], + }, "officialSourceCannotBeRemoved") + community = self.source(PUBLIC_SOURCES[1]) + self.enable_source(community["id"], False) + self.driver.wait_until(lambda: all(item["sourceId"] != community["id"] for item in self.indexes()), + 15, "disabled public source to leave the catalog") + self.enable_source(community["id"], True) + self.remove_source(community["id"]) + added = self.add_source(PUBLIC_SOURCES[1]) + assert added["id"] == community["id"] + + def late_prepare(self, fixture: FixtureCatalog, source: dict, *, remove: bool): + gate = fixture.gate_next_archive() + operation = self.operation() + # Start one genuine host request without awaiting it. Observation lives + # in a separate JS slot; invoke and its result are never replaced. + self.page.evaluate("""([operationId, sourceId, pluginId]) => { + window.__marketplaceAcceptanceResult = {done: false}; + window.__TAURI_INTERNALS__.invoke('declarative_marketplace_prepare', + {operationId, sourceId, pluginId}).then( + value => { window.__marketplaceAcceptanceResult = {done: true, ok: true, value}; }, + error => { window.__marketplaceAcceptanceResult = {done: true, ok: false, error: String(error)}; }, + ); + }""", [operation, source["id"], self.driver.PLUGIN_ID]) + try: + assert gate.arrived.wait(timeout=10), "the real HTTPS archive request did not reach the fixture" + if remove: + self.remove_source(source["id"]) + else: + self.enable_source(source["id"], False) + self.page.wait_for_function("() => window.__marketplaceAcceptanceResult?.done", timeout=15_000) + result = self.page.evaluate("() => window.__marketplaceAcceptanceResult") + assert not result["ok"] and "cancel" in result["error"].lower(), result + self.driver.write_json(self.evidence / f"late-prepare-{'removed' if remove else 'disabled'}.json", result) + finally: + gate.release.set() + self.cancel(operation) + assert gate.finished.wait(timeout=6), "the gated archive handler survived cancellation" + self.no_install() + + def late_ui_prepare(self, fixture: FixtureCatalog, source: dict, other: dict, mode: str): + """Hold real HTTPS bytes until the UI has released its owning context.""" + if mode not in ("cancel", "scope", "dispose"): + raise ValueError(f"unsupported late UI action: {mode}") + gate = fixture.gate_next_archive() + try: + choose_source(self.page, source["id"]) + row = offer_row(self.page, self.driver.PLUGIN_ID) + row.get_by_test_id(f"marketplace-install-{self.driver.PLUGIN_ID}").click(timeout=15_000) + assert gate.arrived.wait(timeout=10), "the UI's real archive request did not reach the fixture" + if mode == "cancel": + row.get_by_role("button", name="Cancel", exact=True).click(timeout=15_000) + elif mode == "scope": + choose_source(self.page, other["id"]) + elif mode == "dispose": + self.driver.close_settings(self.page) + except Exception: + # Unmounting the page releases its own native operation even if + # the assertion failed before we could click the expected action. + self.driver.close_settings(self.page) + raise + finally: + gate.release.set() + # If starting the request itself failed, teardown owns the unused + # gate. A request that arrived must release its active handler here. + if gate.arrived.is_set(): + assert gate.finished.wait(timeout=6), "the UI's gated archive handler survived cancellation" + self.no_install() + self.driver.open_plugin_settings(self.page) + choose_source(self.page, source["id"]) + expect(marketplace(self.page).get_by_test_id("marketplace-review-card")).to_have_count(0) + expect(offer_row(self.page, self.driver.PLUGIN_ID) + .get_by_test_id(f"marketplace-install-{self.driver.PLUGIN_ID}")).to_be_enabled(timeout=15_000) + self.driver.write_json(self.evidence / f"late-ui-{mode}.json", { + "action": mode, "archiveRequestReachedFixture": True, + "reviewAfterRelease": False, "installedPackageAfterRelease": False, + }) + + def run_controlled(self, fixture: FixtureCatalog): + assets = fixture.plan["assets"] + urls = [f"{fixture.base}/source-{name}/index.json" for name in ("a", "b")] + with self.reporter.step(CONTROLLED_CASES[0]): + for source in self.sources(): + if source["isEnabled"]: + self.enable_source(source["id"], False) + a, b = (self.add_source(url) for url in urls) + self.enable_source(a["id"], False) + self.enable_source(a["id"], True) + self.remove_source(b["id"]) + b = self.add_source(urls[1]) + index = self.refresh(a["id"], digest=assets["a1"]["sha256"]) + self.refresh(b["id"], digest=assets["b9"]["sha256"]) + choose_source(self.page, "all") + expect(offer_row(self.page, self.driver.PLUGIN_ID)).to_have_count(1) + expect(offer_row(self.page, self.driver.PLUGIN_ID)).to_contain_text("1.0.0") + choose_source(self.page, b["id"]) + expect(offer_row(self.page, self.driver.PLUGIN_ID)).to_contain_text("9.0.0") + choose_source(self.page, a["id"]) + self.search(index) + with self.reporter.step(CONTROLLED_CASES[1]): + review = self.prepare(a["id"]) + assert review["requiresConfirmation"] is True + assert review["officialPublisherVerified"] is False + self.confirm_rejected(review, "explicit approval is required", approved=False) + self.no_install() + self.confirm_rejected(self.prepare(a["id"]), "review digest does not match", digest="0" * 64) + self.no_install() + with self.reporter.step(CONTROLLED_CASES[2]): + card = self.ui_prepare(a["id"]) + read_review_programs(self.page, marker=assets["a1"]["marker"]) + card.get_by_test_id("marketplace-review-cancel").click() + expect(card).to_have_count(0) + self.no_install() + card = self.ui_prepare(a["id"]) + choose_source(self.page, b["id"]) + expect(card).to_have_count(0) + self.no_install() + with self.reporter.step(LATE_UI_CASE): + for mode in ("cancel", "scope", "dispose"): + self.late_ui_prepare(fixture, a, b, mode) + with self.reporter.step(CONTROLLED_CASES[3]): + self.late_prepare(fixture, a, remove=False) + self.enable_source(a["id"], True) + self.late_prepare(fixture, a, remove=True) + a = self.add_source(urls[0]) + self.refresh(a["id"], digest=assets["a1"]["sha256"]) + # Cancellation may arrive before registration too. + operation = self.operation() + self.cancel(operation) + expect_rejected(self.page, "declarative_marketplace_prepare", { + "operationId": operation, "sourceId": a["id"], "pluginId": self.driver.PLUGIN_ID, + }, "cancel") + self.no_install() + with self.reporter.step(CONTROLLED_CASES[4]): + card = self.ui_prepare(a["id"]) + assert a["indexURL"] in card.inner_text() + programs = read_review_programs(self.page, marker=assets["a1"]["marker"]) + card.get_by_test_id("marketplace-review-confirm").click() + expect(card).to_have_count(0, timeout=45_000) + self.driver.wait_catalog(self.page, "1.0.0", True) + self.assert_record(a, assets["a1"], programs) + with self.reporter.step(CONTROLLED_CASES[5]): + self.page = self.restart() + self.driver.open_plugin_settings(self.page) + self.driver.wait_catalog(self.page, "1.0.0", True) + self.assert_record(a, assets["a1"], programs) + assert self.source(urls[0])["id"] == a["id"] + with self.reporter.step(CONTROLLED_CASES[6]): + choose_source(self.page, b["id"]) + expect(offer_row(self.page, self.driver.PLUGIN_ID) + .get_by_test_id(f"marketplace-install-{self.driver.PLUGIN_ID}")).to_have_count(0) + operation = self.operation() + expect_rejected(self.page, "declarative_marketplace_prepare", { + "operationId": operation, "sourceId": b["id"], "pluginId": self.driver.PLUGIN_ID, + }, "source|marketplace") + self.cancel(operation) + self.assert_record(a, assets["a1"]) + with self.reporter.step(CONTROLLED_CASES[7]): + for phase, digest, pattern in ( + ("bad-digest", "0" * 64, "SHA-256|sha256|digest|hash"), + ("bad-signature", assets["untrusted"]["sha256"], "signature|signing|key|publisher"), + ): + fixture.stage(phase) + self.refresh(a["id"], digest=digest) + operation = self.operation() + error = expect_rejected(self.page, "declarative_marketplace_prepare", { + "operationId": operation, "sourceId": a["id"], "pluginId": self.driver.PLUGIN_ID, + }, pattern) + self.driver.write_json(self.evidence / f"rejected-{phase}.json", {"error": error}) + self.cancel(operation) + self.no_snapshots() + self.assert_record(a, assets["a1"]) + fixture.stage("update") + self.refresh(a["id"], digest=assets["a2"]["sha256"]) + with self.reporter.step(CONTROLLED_CASES[8]): + review = self.prepare(a["id"]) + self.refresh(a["id"], digest=assets["a2"]["sha256"]) + self.confirm_rejected(review, "changed|cancel|no longer|pending|unknown") + self.assert_record(a, assets["a1"]) + with self.reporter.step(CONTROLLED_CASES[9]): + self.driver.close_settings(self.page) + self.driver.open_fixture_file(self.page) + state = self.args.app_local_data / "plugin-state" / self.driver.PLUGIN_ID + live = self.driver.wait_for_server_count(state, 2) + self.pids.update(live) + self.driver.open_plugin_settings(self.page) + self.driver.set_plugin_enabled(self.page, False) + self.driver.wait_pids_dead(live, "marketplace plugin disable") + choose_source(self.page, "all") + # A newer 9.0.0 offer cannot displace the installed source's 1.1.0. + row = offer_row(self.page, self.driver.PLUGIN_ID) + expect(row).to_contain_text("1.1.0") + expect(row).not_to_contain_text("9.0.0") + card = self.ui_prepare(a["id"]) + programs = read_review_programs(self.page, marker=assets["a2"]["marker"]) + card.get_by_test_id("marketplace-review-confirm").click() + expect(card).to_have_count(0, timeout=45_000) + self.driver.wait_catalog(self.page, "1.1.0", False) + assert self.assert_record(a, assets["a2"], programs)["enabled"] is False + with self.reporter.step(CONTROLLED_CASES[10]): + self.driver.set_plugin_enabled(self.page, True) + self.driver.open_fixture_file(self.page) + live = self.driver.wait_for_server_count(state, 2) + self.pids.update(live) + self.driver.open_plugin_settings(self.page) + stale = self.prepare(a["id"]) + details = self.driver.select_plugin_details(self.page) + details.get_by_role("button", name="Uninstall", exact=True).click() + expect(self.driver.plugin_row(self.page)).to_have_count(0, timeout=45_000) + self.driver.wait_pids_dead(live, "marketplace plugin uninstall") + self.confirm_rejected(stale, "changed|cancel|no longer|pending|unknown") + assert not state.exists() + self.no_install() + + def run(self): + self.reporter.document_pending(SIGNATURE_GAP, + "Requires a real Windows artifact signed by the pinned official publisher. Fixture packages are unsigned third-party packages and never establish official-publisher acceptance.") + try: + self.run_public() + if self.args.marketplace_fixture_root is None: + for name in CONTROLLED_CASES: + self.reporter.document_pending(name, + "Needs marketplace_fixture.py generate output plus a reachable HTTPS hostname and an already-trusted certificate/key. No trust-store changes or TLS bypass are permitted.") + else: + fixture = FixtureCatalog(self.args.marketplace_fixture_root.resolve()) + with FixtureServer.native(fixture, self.args.marketplace_tls_cert, + self.args.marketplace_tls_key, self.args.marketplace_listen_host): + try: + self.run_controlled(fixture) + finally: + self.driver.write_json(self.evidence / "fixture-requests.json", fixture.requests) + finally: + cleanup_errors = [] + for operation in list(self.operations): + try: + self.cancel(operation) + except Exception as error: + cleanup_errors.append(str(error)) + if cleanup_errors: + raise RuntimeError("marketplace operation cleanup failed: " + "; ".join(cleanup_errors)) + + +def record_unreached_cases(reporter) -> None: + completed = {test["name"] for test in reporter.tests} + for name in PUBLIC_CASES + CONTROLLED_CASES + (SIGNATURE_GAP,): + if name not in completed: + reporter.document_pending(name, "An earlier acceptance case failed; this native marketplace case was not executed.") diff --git a/scripts/plugin-host-acceptance/marketplace_acceptance_test.py b/scripts/plugin-host-acceptance/marketplace_acceptance_test.py new file mode 100644 index 0000000000..cadadc68e2 --- /dev/null +++ b/scripts/plugin-host-acceptance/marketplace_acceptance_test.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Bounded fixture/locator regressions. These are never native product acceptance.""" + +from __future__ import annotations + +import argparse +import contextlib +import hashlib +import io +import json +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.request +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import acceptance_driver as driver +from marketplace_acceptance import ( + CONTROLLED_CASES, PUBLIC_CASES, MarketplaceAcceptance, add_arguments, + choose_source, expect_rejected, marketplace, native_call, offer_row, + read_review_programs, record_unreached_cases, source_row, validate_arguments, +) +from marketplace_fixture import ( + FixtureCatalog, FixtureServer, HttpsRedirects, PHASES, + generate_fixture, https_base, +) +from playwright.sync_api import expect, sync_playwright + + +BASE = "https://marketplace.acceptance.invalid:8443/run" +SERVER = Path(__file__).with_name("fake-lsp-server.mjs") + + +def rejects(action, error_type=ValueError): + try: + action() + except error_type: + return + raise AssertionError(f"expected {error_type.__name__}") + + +def fixture_case(action): + with tempfile.TemporaryDirectory(prefix="lithe-market-fixture-") as directory: + root = Path(directory) / "fixture" + generate_fixture(root, BASE, SERVER) + action(root) + + +def deterministic_archives(): + with tempfile.TemporaryDirectory(prefix="lithe-market-determinism-") as directory: + roots = [Path(directory) / name for name in ("one", "two")] + plans = [generate_fixture(root, BASE, SERVER) for root in roots] + assert plans[0] == plans[1] + for name, asset in plans[0]["assets"].items(): + first, second = (root / asset["file"] for root in roots) + assert first.read_bytes() == second.read_bytes(), name + assert hashlib.sha256(first.read_bytes()).hexdigest() == asset["sha256"] + with zipfile.ZipFile(first) as archive: + manifest = json.loads(archive.read("plugin.json")) + assert manifest["id"] == driver.PLUGIN_ID + assert manifest["version"] == asset["version"] + assert manifest["vendor"]["signatureRequirement"] == "none" + assert manifest["permissions"] == ["languageServer.launch"] + assert manifest["languageServers"][0]["arguments"][-1] == asset["marker"] + if name == "untrusted": + signature = json.loads(archive.read("lithe-plugin-signature.json")) + assert signature["keyId"] == "acceptance-untrusted" + else: + assert "lithe-plugin-signature.json" not in archive.namelist() + rejects(lambda: generate_fixture(roots[0], BASE, SERVER), FileExistsError) + + +def https_configuration_only(): + assert https_base(BASE + "/") == BASE + for invalid in ("http://localhost:8000", "file:///fixture", "https://user:secret@example.com", + "https://example.com/a?token=secret", "https://example.com/#section", + "https://example.com/../other", "https://example.com:0"): + rejects(lambda: https_base(invalid)) + redirect = HttpsRedirects() + rejects(lambda: redirect.redirect_request(None, None, 302, "", {}, "http://example.com/index.json")) + parser = argparse.ArgumentParser() + add_arguments(parser) + plain = parser.parse_args(["--marketplace"]) + validate_arguments(parser, plain) + with contextlib.redirect_stderr(io.StringIO()): + partial = parser.parse_args(["--marketplace", "--marketplace-fixture-root", "fixture"]) + rejects(lambda: validate_arguments(parser, partial), SystemExit) + + +def phases_and_corruption(root): + catalog = FixtureCatalog(root) + a = catalog.prefix + "/source-a/index.json" + b = catalog.prefix + "/source-b/index.json" + assert json.loads(catalog.response(b)[0])["plugins"][0]["versions"][0]["version"] == "9.0.0" + for phase in PHASES: + catalog.stage(phase) + document = json.loads(catalog.response(a)[0]) + version = document["plugins"][0]["versions"][0] + download = version["downloads"][0] + assert download["platforms"] == ["windows-x64"] + assert download["downloadURL"].startswith(BASE + "/packages/") + if phase == "bad-digest": + assert download["sha256"] == "0" * 64 + rejects(lambda: catalog.stage("outside-plan")) + assert catalog.response(catalog.prefix + "/../plan.json")[0] is None + (root / "packages/a1.zip").write_bytes(b"changed after generation") + rejects(lambda: FixtureCatalog(root)) + + +def gated_http_unit(root): + catalog = FixtureCatalog(root) + gate = catalog.gate_next_archive() + replies = [] + with FixtureServer(catalog, ("127.0.0.1", 0), None) as server: + url = f"http://127.0.0.1:{server.server.server_port}{catalog.prefix}/packages/a1.zip" + def request(): + try: + with urllib.request.urlopen(url, timeout=3) as response: + replies.append(response.read()) + except Exception as error: + replies.append(error) + client = threading.Thread(target=request, name="marketplace-unit-client") + client.start() + try: + assert gate.arrived.wait(timeout=2) + assert not gate.finished.is_set() + gate.release.set() + assert gate.finished.wait(timeout=2) + finally: + gate.release.set() + client.join(timeout=4) + assert not client.is_alive(), "the unit HTTP client survived its deadline" + assert len(replies) == 1 and isinstance(replies[0], bytes), replies + assert hashlib.sha256(replies[0]).hexdigest() == catalog.plan["assets"]["a1"]["sha256"] + with urllib.request.urlopen( + f"http://127.0.0.1:{server.server.server_port}{catalog.prefix}/source-a/index.json", timeout=2, + ) as response: + assert json.load(response) == catalog.plan["indexes"]["initial"] + try: + urllib.request.urlopen(f"http://127.0.0.1:{server.server.server_port}/plan.json", timeout=2) + except urllib.error.HTTPError as error: + assert error.code == 404 + else: + raise AssertionError("the fixture exposed its local plan or arbitrary files") + assert not server.thread.is_alive() + + +def shutdown_releases_gate(root): + catalog = FixtureCatalog(root) + gate = catalog.gate_next_archive() + server = FixtureServer(catalog, ("127.0.0.1", 0), None) + replies = [] + def request(): + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{server.server.server_port}{catalog.prefix}/packages/a1.zip", timeout=3, + ) as response: + replies.append(response.read()) + except Exception as error: + replies.append(error) + client = threading.Thread(target=request, name="marketplace-cancelled-unit-client") + try: + with server: + client.start() + assert gate.arrived.wait(timeout=2) + # Assertion failures and cancellation must not leave a held request. + finally: + gate.release.set() + client.join(timeout=4) + assert not client.is_alive() and not server.thread.is_alive() + assert gate.finished.is_set() + assert len(replies) == 1 and isinstance(replies[0], Exception), replies + + +def missing_native_prerequisites(root): + reporter = driver.Reporter() + args = SimpleNamespace(artifact_root=root / "evidence", marketplace_fixture_root=None) + scenario = MarketplaceAcceptance(driver, args, reporter, None, None, set()) + with patch.object(scenario, "run_public"), patch.object(scenario, "run_controlled") as controlled: + scenario.run() + controlled.assert_not_called() + controlled_records = [test for test in reporter.tests if test["name"] in CONTROLLED_CASES] + assert len(controlled_records) == len(CONTROLLED_CASES) + assert all(test["status"] == "skipped" for test in controlled_records) + record_unreached_cases(reporter) + assert all(test["status"] == "skipped" for test in reporter.tests) + assert all(any(test["name"] == name for test in reporter.tests) for name in PUBLIC_CASES + CONTROLLED_CASES) + + +def marketplace_dom(browser_channel: str): + """Selectors and IPC argument regression against explicit HTML/native doubles.""" + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel=browser_channel, headless=True, timeout=10_000) + try: + page = browser.new_page() + page.set_default_timeout(2_000) + programs = [ + {"kind": "runtime", "tool": "node"}, + {"kind": "runtimeLanguageServer", "tool": "node", "packagedRuntime": None, + "entry": "server/fake-lsp-server.mjs", "runtimeArguments": [], + "arguments": ["--stdio", "--state-dir", "${workspaceStateDir}", + "--fixture-version", "market-a-1"], "validationArguments": [], + "languageID": "litheaccept"}, + ] + page.set_content(f""" +
    +
  • Trap
  • +
    +
    +
    • Same name

      + +
    • +
    • Same name

      + +
    +
    +
    + +
    • + {driver.DISPLAY_NAME} · 1.0.0 +
    + +
    +
    + """, timeout=2_000) + page.locator("#programs").evaluate("(node, value) => node.textContent = JSON.stringify(value)", programs) + choose_source(page, "b") + expect(marketplace(page).get_by_test_id("marketplace-source-select")).to_have_value("b") + expect(offer_row(page, driver.PLUGIN_ID)).to_have_count(1) + source_row(page, "a").get_by_role("button", name="Remove", exact=True).click() + expect(page.locator("body")).to_have_attribute("data-removed", "a") + offer_row(page, driver.PLUGIN_ID).get_by_test_id(f"marketplace-install-{driver.PLUGIN_ID}").click() + assert read_review_programs(page, marker="market-a-1") == programs + # A truncated declaration must fail; the regression cannot merely + # check that 'node' appears somewhere in the approval card. + page.locator("#programs").evaluate("(node) => node.textContent = '[{\"tool\":\"node\"}]'") + rejects(lambda: read_review_programs(page, marker="market-a-1"), AssertionError) + marketplace(page).get_by_test_id("marketplace-review-cancel").click() + expect(marketplace(page).get_by_test_id("marketplace-review-card")).to_have_count(0) + page.evaluate("""() => { + window.__TAURI_INTERNALS__ = {invoke: async (command, args) => { + window.__observed = {command, args}; + if (args.approved === false) throw new Error('explicit approval is required'); + return {fixtureOnly: true}; + }}; + }""") + expect_rejected(page, "declarative_marketplace_confirm", { + "operationId": "unit-review", "manifestDigest": "a" * 64, "approved": False, + }, "approval is required") + observed = page.evaluate("() => window.__observed") + assert observed["args"]["approved"] is False + assert observed["command"] == "declarative_marketplace_confirm" + assert native_call(page, "unit-observer-only") == {"fixtureOnly": True} + assert page.locator("body").get_attribute("data-trap") is None + finally: + browser.close() + + +def run_tests(args) -> int: + tests = [] + def case(name, action, max_ms=10_000): + started = time.monotonic() + status, details = "passed", "" + try: + action() + except Exception as error: + status, details = "failed", f"{type(error).__name__}: {error}" + tests.append({"name": name, "suite": "plugin-marketplace-acceptance / fixture-and-locator-unit", + "status": status, "durationMs": round((time.monotonic() - started) * 1000, 3), + "maxMs": max_ms, "details": details}) + driver.write_json(args.report, {"runner": "acceptance", "warnMs": 2000, "maxMs": 10000, "tests": tests}) + if status != "passed": + raise AssertionError(details) + case("marketplace fixture archives and approval inputs are deterministic", deterministic_archives) + case("native fixture configuration rejects HTTP and partial TLS arguments", https_configuration_only) + case("fixture phases preserve selected bytes and reject modified archives", lambda: fixture_case(phases_and_corruption)) + case("unit HTTP gate releases the exact archive and denies arbitrary paths", lambda: fixture_case(gated_http_unit)) + case("fixture teardown releases a held request and listener", lambda: fixture_case(shutdown_releases_gate)) + case("missing native prerequisites remain explicitly skipped", lambda: fixture_case(missing_native_prerequisites)) + if args.dom_browser: + def dom_child(): + result = driver.run_bounded_process([ + sys.executable, "-B", str(Path(__file__).resolve()), + "--dom-browser", args.dom_browser, "--dom-fixture-child", + ], timeout_seconds=30) + assert result.returncode == 0, result.stderr or result.stdout + case("HTML marketplace selectors and approval IPC preserve scope and full declarations", dom_child, max_ms=35_000) + else: + tests.append({"name": "HTML marketplace locator regression", "suite": "plugin-marketplace-acceptance / fixture-and-locator-unit", + "status": "skipped", "durationMs": 0, "maxMs": 35_000, + "details": "Pass --dom-browser msedge; no Lithe product is launched by this unit fixture."}) + driver.write_json(args.report, {"runner": "acceptance", "warnMs": 2000, "maxMs": 10000, "tests": tests}) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", type=Path) + parser.add_argument("--dom-browser", choices=["msedge"]) + parser.add_argument("--test-child", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--dom-fixture-child", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + if args.dom_fixture_child: + if not args.dom_browser: + parser.error("--dom-fixture-child requires --dom-browser") + marketplace_dom(args.dom_browser) + return 0 + if not args.report: + parser.error("--report is required") + if args.test_child: + return run_tests(args) + command = [sys.executable, "-B", str(Path(__file__).resolve()), "--report", str(args.report.resolve()), "--test-child"] + if args.dom_browser: + command.extend(["--dom-browser", args.dom_browser]) + try: + result = driver.run_bounded_process(command, timeout_seconds=90) + print(result.stdout, end="") + print(result.stderr, end="", file=sys.stderr) + return result.returncode + except TimeoutError as error: + report = json.loads(args.report.read_text(encoding="utf-8")) if args.report.exists() else {"runner": "acceptance", "tests": []} + report["tests"].append({"name": "marketplace unit process watchdog", "suite": "plugin-marketplace-acceptance / fixture-and-locator-unit", + "status": "timeout", "durationMs": 90000, "maxMs": 90000, "details": str(error)}) + driver.write_json(args.report, report) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plugin-host-acceptance/marketplace_fixture.py b/scripts/plugin-host-acceptance/marketplace_fixture.py new file mode 100644 index 0000000000..b020337cd1 --- /dev/null +++ b/scripts/plugin-host-acceptance/marketplace_fixture.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""Deterministic marketplace inputs; TLS and publisher trust stay production-owned. + +Generated files are disposable test inputs under the caller's artifact directory, +never a reusable build cache or installed resource. Native serving requires an +existing certificate trusted for the supplied HTTPS hostname. This module neither +creates certificates nor modifies client trust, DNS, or the production transport. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import socket +import ssl +import threading +import urllib.parse +import urllib.request +import zipfile +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +from generate_packages import DISPLAY_NAME, PLUGIN_ID, write_package + + +PUBLIC_SOURCES = ( + "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json", + "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-store/HEAD/lithe-plugin-index.json", +) +PHASES = ("initial", "update", "bad-digest", "bad-signature") +MAX_INDEX_BYTES = 1024 * 1024 + + +def https_base(value: str) -> str: + parsed = urllib.parse.urlsplit(value) + if (parsed.scheme != "https" or not parsed.hostname or parsed.username is not None + or parsed.password is not None or parsed.query or parsed.fragment + or any(part in (".", "..") for part in parsed.path.split("/"))): + raise ValueError("the fixture needs a credential-free HTTPS base URL without query/fragment") + if parsed.port == 0: + raise ValueError("the public HTTPS endpoint needs a fixed port") + return value.rstrip("/") + + +def json_bytes(value) -> bytes: + return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + + +def entry(base: str, version: str, archive_name: str, digest: str) -> dict: + return { + "id": PLUGIN_ID, "name": DISPLAY_NAME, "author": "Lithe acceptance fixture", + "repository": "https://github.com/example/lithe-marketplace-acceptance", + "description": "Controlled Windows marketplace acceptance fixture", + "tags": ["acceptance", "language"], "license": "MIT", + "versions": [{"version": version, "pluginAPIVersion": 2, + "releaseDate": "2026-01-01", "downloads": [{ + "platforms": ["windows-x64"], + "downloadURL": f"{base}/packages/{archive_name}", "sha256": digest, + }]}], + } + + +def generate_fixture(output: Path, base_url: str, server_source: Path) -> dict: + base = https_base(base_url) + # Never clear an existing directory: it may contain another run's evidence. + output.mkdir(parents=True, exist_ok=False) + packages = output / "packages" + packages.mkdir() + specs = (("a1", "1.0.0", "market-a-1"), ("a2", "1.1.0", "market-a-2"), + ("b9", "9.0.0", "market-b-9"), ("untrusted", "1.2.0", "market-untrusted")) + assets = {} + for name, version, marker in specs: + path = write_package(packages, server_source, name, version, marker) + if name == "untrusted": + # An untrusted publisher document must be rejected even for an + # otherwise unsigned community package; no test key is trusted. + with zipfile.ZipFile(path, "a", compression=zipfile.ZIP_DEFLATED) as archive: + info = zipfile.ZipInfo("lithe-plugin-signature.json", (2026, 1, 1, 0, 0, 0)) + info.external_attr = 0o100644 << 16 + archive.writestr(info, json_bytes({ + "schemaVersion": 1, "algorithm": "ed25519", "keyId": "acceptance-untrusted", + "pluginId": PLUGIN_ID, "pluginVersion": version, "files": {}, + "signature": "AA==", + })) + assets[name] = {"file": f"packages/{name}.zip", "version": version, "marker": marker, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} + indexes = {} + for phase, asset_name in zip(PHASES, ("a1", "a2", "a2", "untrusted")): + asset = assets[asset_name] + digest = "0" * 64 if phase == "bad-digest" else asset["sha256"] + indexes[phase] = {"schemaVersion": 1, "plugins": [ + entry(base, asset["version"], Path(asset["file"]).name, digest), + ]} + b = assets["b9"] + plan = {"schemaVersion": 1, "baseURL": base, "pluginId": PLUGIN_ID, + "assets": assets, "indexes": indexes, + "competingIndex": {"schemaVersion": 1, "plugins": [ + entry(base, b["version"], "b9.zip", b["sha256"]), + ]}, + "trust": "unsigned third-party fixture; explicit native approval required; never official"} + (output / "plan.json").write_bytes(json_bytes(plan)) + return plan + + +class RequestGate: + """The test owns arrival/release; a broken scenario cannot hold a server forever.""" + + def __init__(self): + self.arrived = threading.Event() + self.release = threading.Event() + self.finished = threading.Event() + + +class FixtureCatalog: + def __init__(self, root: Path): + self.plan = json.loads((root / "plan.json").read_text(encoding="utf-8")) + if self.plan.get("schemaVersion") != 1 or self.plan.get("pluginId") != PLUGIN_ID: + raise ValueError("unsupported marketplace fixture plan") + self.base = https_base(self.plan["baseURL"]) + self.prefix = urllib.parse.urlsplit(self.base).path + self.assets = {} + for name, asset in self.plan["assets"].items(): + if name not in ("a1", "a2", "b9", "untrusted") or asset["file"] != f"packages/{name}.zip": + raise ValueError("unexpected fixture asset path") + path = root / asset["file"] + if path.is_symlink() or path.parent.is_symlink() or path.parent.is_junction(): + raise ValueError("fixture archives cannot be linked outside the fixture root") + data = path.read_bytes() + if hashlib.sha256(data).hexdigest() != asset["sha256"]: + raise ValueError(f"fixture archive changed: {name}") + self.assets[f"{self.prefix}/{asset['file']}"] = data + if set(self.plan["indexes"]) != set(PHASES): + raise ValueError("the fixture is missing a required index phase") + self.phase = "initial" + self.lock = threading.Lock() + self.gates: dict[str, RequestGate] = {} + self.requests: list[dict] = [] + + def stage(self, phase: str) -> None: + if phase not in PHASES: + raise ValueError(f"unknown fixture phase: {phase}") + with self.lock: + self.phase = phase + + def gate_next_archive(self, name: str = "a1") -> RequestGate: + path = f"{self.prefix}/packages/{name}.zip" + with self.lock: + if path not in self.assets or path in self.gates: + raise ValueError("unknown or already gated archive") + gate = RequestGate() + self.gates[path] = gate + return gate + + def response(self, path: str): + with self.lock: + self.requests.append({"path": path, "phase": self.phase}) + gate = self.gates.pop(path, None) + if path == f"{self.prefix}/source-a/index.json": + return json_bytes(self.plan["indexes"][self.phase]), "application/json", gate + if path == f"{self.prefix}/source-b/index.json": + return json_bytes(self.plan["competingIndex"]), "application/json", gate + return self.assets.get(path), "application/zip", gate + + +class FixtureServer: + """Own one bounded listener thread. HTTP without TLS is only used by unit tests. + + Native callers must use `native`; it has no HTTP mode, extra client roots, + certificate-ignore option, or public control endpoint. Staging and gates are + local Python objects, so downloaded content cannot control the test host. + """ + + def __init__(self, catalog: FixtureCatalog, address, tls_context: ssl.SSLContext | None): + self.catalog = catalog + self.stop = threading.Event() + self.active_socket = None + self.active_gate = None + self.errors = [] + owner = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + data, content_type, gate = catalog.response(self.path) + if data is None: + self.send_error(404) + return + owner.active_gate = gate + try: + if gate is not None: + gate.arrived.set() + if not gate.release.wait(timeout=30): + self.send_error(504, "fixture gate exceeded its local deadline") + return + if owner.stop.is_set(): + return + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(data) + except (ConnectionError, OSError): + # Cancellation deliberately closes the native download. + pass + finally: + if gate is not None: + gate.finished.set() + owner.active_gate = None + + def log_message(self, _format, *_args): + pass + + class Server(HTTPServer): + def get_request(self): + connection, client = super().get_request() + connection.settimeout(5) + owner.active_socket = connection + try: + if tls_context is not None: + connection = tls_context.wrap_socket(connection, server_side=True, + do_handshake_on_connect=False) + owner.active_socket = connection + connection.do_handshake() + return connection, client + except Exception: + connection.close() + owner.active_socket = None + raise + + def handle_error(self, _request, _client): + owner.errors.append("fixture request handler failed") + + def close_request(self, request): + super().close_request(request) + owner.active_socket = None + + self.server = Server(address, Handler) + self.server.timeout = 0.1 + self.thread = threading.Thread(target=self._run, name="marketplace-fixture-server") + + @classmethod + def native(cls, catalog: FixtureCatalog, cert: Path, key: Path, listen_host: str): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(certfile=cert, keyfile=key) + port = urllib.parse.urlsplit(catalog.base).port or 443 + return cls(catalog, (listen_host, port), context) + + def _run(self): + while not self.stop.is_set(): + self.server.handle_request() + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, *_args): + self.stop.set() + if self.active_gate is not None: + self.active_gate.release.set() + connection = self.active_socket + if connection is not None: + try: + connection.shutdown(socket.SHUT_RDWR) + except OSError: + pass + self.thread.join(timeout=6) + self.server.server_close() + if self.thread.is_alive(): + raise RuntimeError("the fixture listener survived its six-second cleanup deadline") + if self.errors: + raise RuntimeError("; ".join(self.errors)) + + +class HttpsRedirects(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, url): + https_base(url) + return super().redirect_request(request, response, code, message, headers, url) + + +def probe_public_sources() -> dict: + """Network inventory only; neither Core validation nor native acceptance.""" + opener = urllib.request.build_opener(HttpsRedirects()) + results = [] + for url in PUBLIC_SOURCES: + result = {"url": url, "nativeAcceptance": False} + try: + with opener.open(url, timeout=10) as response: + data = response.read(MAX_INDEX_BYTES + 1) + if len(data) > MAX_INDEX_BYTES: + raise ValueError("public index exceeds the probe's byte limit") + document = json.loads(data) + windows = [] + for plugin in document["plugins"]: + for version in plugin.get("versions", []): + for download in version.get("downloads", []): + if "windows-x64" in download.get("platforms", []): + windows.append({"id": plugin["id"], "version": version["version"]}) + result.update(sha256=hashlib.sha256(data).hexdigest(), + pluginCount=len(document["plugins"]), windowsX64Downloads=windows) + except Exception as error: + result["error"] = f"{type(error).__name__}: {error}" + results.append(result) + return {"kind": "HTTPS index inventory; no product was launched", "sources": results} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + generate = sub.add_parser("generate") + generate.add_argument("--output", required=True, type=Path) + generate.add_argument("--base-url", required=True) + generate.add_argument("--server", type=Path, default=Path(__file__).with_name("fake-lsp-server.mjs")) + probe = sub.add_parser("probe-public") + probe.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + if args.command == "generate": + generate_fixture(args.output.resolve(), args.base_url, args.server.resolve()) + print(f"Fixture prepared: {args.output.resolve() / 'plan.json'}; native execution remains pending") + else: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_bytes(json_bytes(probe_public_sources())) + print(f"Public HTTPS inventory: {args.output.resolve()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 13d69384ef..368c33862d 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -88,6 +88,8 @@ async function readRegistry() { // Window/operation-owned command-model files stay in mutable plugin state // and use official-plugin-packages' exclusion; a decoded model is not a reusable artifact. // Core validation or a matching archive hash never transfers user ownership. + // Native acceptance fixtures/profiles use plugin-host-acceptance-state's + // refusal route; deterministic test ZIPs do not establish a build identity. const excludedResources = registry.excludedResources ?? []; if (!Array.isArray(excludedResources)) throw new Error("Invalid excluded worktree resources"); for (const resource of excludedResources) { diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index e09c900a79..be8faa7621 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -258,6 +258,21 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /official-plugin-packages.*cannot be reused/); }); + await test("native acceptance refuses copying even a deterministic marketplace fixture", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "plugin-host-acceptance-state"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes(".artifacts/plugin-host-acceptance/")); + const fixture = ".artifacts/plugin-host-acceptance/marketplace-fixture/packages/a1.zip"; + const source = path.join(sourceRoot, fixture); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.writeFile(source, "deterministic fixture bytes"); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /plugin-host-acceptance-state.*cannot be reused/); + assert.equal(await fs.readFile(source, "utf8"), "deterministic fixture bytes"); + await assert.rejects(fs.access(path.join(targetRoot, fixture))); + }); await test("retained MCP filters and native review screenshots remain user/run state", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const policies = registry.excludedResources.find(item => item.id === "mcp-tool-probe-state"); diff --git a/scripts/test-windows-declarative-plugin-host.ps1 b/scripts/test-windows-declarative-plugin-host.ps1 index 14cbacdb1d..2e1a8677c3 100644 --- a/scripts/test-windows-declarative-plugin-host.ps1 +++ b/scripts/test-windows-declarative-plugin-host.ps1 @@ -4,13 +4,23 @@ param( [string]$RustTarget = "x86_64-pc-windows-msvc", [switch]$SkipBuild, [switch]$ResumeBuild, - [string]$ResourceSource = "" + [string]$ResourceSource = "", + [switch]$Marketplace, + [string]$MarketplaceFixtureRoot = "", + [string]$MarketplaceTlsCert = "", + [string]$MarketplaceTlsKey = "", + [string]$MarketplaceListenHost = "127.0.0.1" ) $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest if (-not $IsWindows) { throw "The declarative plugin host acceptance requires Windows." } if ($SkipBuild -and $ResumeBuild) { throw "Choose either -SkipBuild or -ResumeBuild." } +$controlledMarketplaceArguments = @($MarketplaceFixtureRoot, $MarketplaceTlsCert, $MarketplaceTlsKey) +$providedMarketplaceArguments = @($controlledMarketplaceArguments | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +if ($providedMarketplaceArguments.Count -gt 0 -and (-not $Marketplace -or $providedMarketplaceArguments.Count -ne 3)) { + throw "Controlled marketplace acceptance requires -Marketplace, -MarketplaceFixtureRoot, -MarketplaceTlsCert and -MarketplaceTlsKey together." +} $root = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot)) $artifactParent = [IO.Path]::GetFullPath((Join-Path $root ".artifacts")) @@ -115,7 +125,15 @@ function Write-NormalizedReport { function Stop-OwnedAcceptanceProcesses { param([Parameter(Mandatory)][string]$StagedExecutable) $executablePath = [IO.Path]::GetFullPath($StagedExecutable) - $snapshot = @(Get-CimInstance Win32_Process) + # Share the driver's upstream native inspector. Full CIM enumeration can + # hang on an unrelated process even when every owned process has exited. + $inventoryPath = Join-Path $evidenceRoot "wrapper-process-inventory.json" + $inventoryCode = "import sys,json,pathlib; sys.path.insert(0,sys.argv[1]); import acceptance_driver as d; print(json.dumps(d.acceptance_process_inventory(pathlib.Path(sys.argv[2]),sys.argv[3]),ensure_ascii=True))" + $inventoryExit = Invoke-BoundedDriver -Arguments @( + "-B", "-c", $inventoryCode, $fixtureRoot, $executablePath, $acceptanceIdentifier + ) -LogPath $inventoryPath -TimeoutSeconds 15 + if ($inventoryExit -ne 0) { throw "Could not inspect acceptance-owned processes; see $inventoryPath" } + $snapshot = @(Get-Content -LiteralPath $inventoryPath -Raw | ConvertFrom-Json) $survivors = @($snapshot | Where-Object { ($_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $executablePath) -or ($_.CommandLine -and @@ -140,15 +158,15 @@ function Stop-OwnedAcceptanceProcesses { $owner = [Diagnostics.Process]::GetProcessById($processId) $null = $owner.Handle if ($owner.HasExited) { continue } - $created = $record.CreationDate.ToUniversalTime().ToFileTimeUtc() + $created = [int64]$record.CreationTime $actualCreated = $owner.StartTime.ToUniversalTime().ToFileTimeUtc() - if ([Math]::Floor($created / 10) -ne [Math]::Floor($actualCreated / 10)) { continue } + if ($created -ne $actualCreated) { continue } if (-not $record.ExecutablePath -or [IO.Path]::GetFullPath($owner.MainModule.FileName) -ne [IO.Path]::GetFullPath($record.ExecutablePath)) { continue } $owners.Add($owner) $retained = $true foreach ($child in $snapshot) { - if ($child.ParentProcessId -eq $processId -and $child.CreationDate -ge $record.CreationDate) { + if ($child.ParentProcessId -eq $processId -and $child.CreationTime -ge $record.CreationTime) { $pending.Enqueue($child) } } @@ -215,12 +233,17 @@ $customConfig = Join-Path $artifactRoot "tauri.host-acceptance.conf.json" Invoke-NativeChecked -Executable "node" -Arguments @("--check", (Join-Path $fixtureRoot "fake-lsp-server.mjs")) ` -FailureMessage "The controlled language-server fixture has invalid JavaScript" +Invoke-NativeChecked -Executable "python" -Arguments @("-B", "-c", "import playwright,psutil") ` + -FailureMessage "Install acceptance dependencies with python -m pip install playwright==1.60.0 psutil==7.2.2" Invoke-NativeChecked -Executable "python" -Arguments @( "-B", "-c", "import ast,pathlib; [ast.parse(pathlib.Path(p).read_text(encoding='utf-8')) for p in __import__('sys').argv[1:]]", (Join-Path $fixtureRoot "acceptance_driver.py"), (Join-Path $fixtureRoot "acceptance_driver_test.py"), + (Join-Path $fixtureRoot "marketplace_acceptance.py"), + (Join-Path $fixtureRoot "marketplace_acceptance_test.py"), + (Join-Path $fixtureRoot "marketplace_fixture.py"), (Join-Path $fixtureRoot "generate_packages.py") ) -FailureMessage "A plugin host acceptance Python source file has invalid syntax" @@ -236,6 +259,12 @@ Invoke-NativeChecked -Executable "python" -Arguments @( "--report", $driverUnitReport ) -FailureMessage "The acceptance process-owner unit checks failed" +Invoke-NativeChecked -Executable "python" -Arguments @( + "-B", + (Join-Path $fixtureRoot "marketplace_acceptance_test.py"), + "--report", (Join-Path $stabilityRoot "plugin-marketplace-acceptance-driver.json") +) -FailureMessage "The marketplace fixture and locator unit checks failed" + $zsh = Get-Command zsh -ErrorAction SilentlyContinue $immutabilityReport = Join-Path $stabilityRoot "plugin-host-acceptance-source-guard.json" if ($null -eq $zsh) { @@ -254,11 +283,11 @@ if ($null -eq $zsh) { } if (-not $SkipBuild) { - $heavyProcesses = @(Get-CimInstance Win32_Process | Where-Object { - $_.Name -in @("cargo.exe", "rustc.exe") - }) + # Only presence is needed here; avoid a full CIM inventory whose unrelated + # process properties can fail before the build even starts. + $heavyProcesses = @(Get-Process -Name cargo, rustc -ErrorAction SilentlyContinue) if ($heavyProcesses.Count -gt 0) { - throw "Refusing to overlap the formal Release build with existing Cargo/Rust processes: $($heavyProcesses.ProcessId -join ', ')" + throw "Refusing to overlap the formal Release build with existing Cargo/Rust processes: $($heavyProcesses.Id -join ', ')" } if (-not [string]::IsNullOrWhiteSpace($ResourceSource)) { @@ -393,6 +422,7 @@ $workspaceOne = Join-Path $workspaces "one" $workspaceTwo = Join-Path $workspaces "two" New-Item -ItemType Directory -Path $workspaceOne, $workspaceTwo -Force | Out-Null Set-Content -LiteralPath (Join-Path $workspaceOne "acceptance.litheaccept") -Value "workspace one" -Encoding utf8NoBOM +Set-Content -LiteralPath (Join-Path $workspaceOne "uninstall.litheaccept") -Value "uninstall workflow" -Encoding utf8NoBOM Set-Content -LiteralPath (Join-Path $workspaceTwo "acceptance.litheaccept") -Value "workspace two" -Encoding utf8NoBOM $appLocalData = Join-Path ([Environment]::GetFolderPath("LocalApplicationData")) $acceptanceIdentifier @@ -420,7 +450,7 @@ $driverLog = Join-Path $artifactRoot "acceptance-driver.log" $driverExit = 1 $cleanupFailure = $null try { - $driverExit = Invoke-BoundedDriver -LogPath $driverLog -Arguments @( + $driverArguments = @( "-B", (Join-Path $fixtureRoot "acceptance_driver.py"), "--executable", $stagedExecutable, "--run-image", $runImage, @@ -435,6 +465,16 @@ try { "--workspace-one", $workspaceOne, "--workspace-two", $workspaceTwo ) + if ($Marketplace) { $driverArguments += "--marketplace" } + if ($providedMarketplaceArguments.Count -eq 3) { + $driverArguments += @( + "--marketplace-fixture-root", ([IO.Path]::GetFullPath($MarketplaceFixtureRoot)), + "--marketplace-tls-cert", ([IO.Path]::GetFullPath($MarketplaceTlsCert)), + "--marketplace-tls-key", ([IO.Path]::GetFullPath($MarketplaceTlsKey)), + "--marketplace-listen-host", $MarketplaceListenHost + ) + } + $driverExit = Invoke-BoundedDriver -LogPath $driverLog -Arguments $driverArguments } catch { $driverFailure = $_.ToString() Write-NormalizedReport -Path $acceptanceReport -Name "native acceptance driver deadline or startup" ` diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index a51dca16be..74bdd9a10f 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -33,6 +33,17 @@ } ], "excludedResources": [ + { + "id": "plugin-host-acceptance-state", + "reusable": false, + "locations": [ + ".artifacts/plugin-host-acceptance/", + "/app.lithe.windows.host-acceptance/", + "/app.lithe.windows.host-acceptance/" + ], + "identity": "One isolated native acceptance invocation owns its generated marketplace ZIPs/indexes, run image, reports, profiles and mutable installation state. Deterministic fixture hashes verify the selected test bytes, not a reusable source/platform/architecture/toolchain build stamp.", + "reason": "Regenerate fixtures in the target worktree. Never copy or share the run image, user profile, downloaded packages or TLS private keys through resource reuse. The driver owns bounded listeners and process handles, closes them on every path and removes its isolated app data; diagnostic artifacts remain with that run. Native HTTPS uses an existing trusted certificate without copying keys or mutating client trust. No writes enter published resources, signatures or update baselines." + }, { "id": "agent-edit-and-paste-state", "reusable": false, diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index ec324330fb..ab2fb30e37 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9311,6 +9311,36 @@ "settings.declarativePlugins.sources.errorGeneric": "The source could not be updated. Try again.", "settings.declarativePlugins.sources.corruptNotice": "The saved source list was unreadable. The original file was preserved next to the store and the list was rebuilt from the presets.", "settings.declarativePlugins.sources.retry": "Retry", + "buildSystems.model.title": "Build model", + "buildSystems.model.load": "Load model…", + "buildSystems.model.reload": "Reload model…", + "buildSystems.model.cancel": "Cancel model loading", + "buildSystems.model.retryCancel": "Retry cancellation", + "buildSystems.model.reviewTitle": "Review model loading", + "buildSystems.model.approve": "Allow and load", + "buildSystems.model.decline": "Decline", + "buildSystems.model.plugin": "Plugin", + "buildSystems.model.buildSystem": "Build system", + "buildSystems.model.workspace": "Workspace", + "buildSystems.model.instance": "Instance", + "buildSystems.model.operation": "Operation", + "buildSystems.model.generation": "Generation", + "buildSystems.model.projectCode": "This operation may execute code from the project.", + "buildSystems.model.noProjectCode": "The prepared plan does not declare project code execution.", + "buildSystems.model.network": "This operation may access the network.", + "buildSystems.model.noNetwork": "The prepared plan does not declare network access.", + "buildSystems.model.failed": "Model loading failed: {message}. Check the tool or project configuration, then retry.", + "buildSystems.model.cancelFailed": "Cancellation failed: {message}. Retry cancellation before starting another load.", + "buildSystems.model.stderr": "Model loading stderr", + "buildSystems.model.phase.preparing": "Preparing model review…", + "buildSystems.model.phase.review": "Waiting for your decision", + "buildSystems.model.phase.loading": "Loading model…", + "buildSystems.model.phase.stopping": "Cancelling; waiting for operation cleanup…", + "buildSystems.model.phase.ready": "Model loaded", + "buildSystems.model.phase.declined": "Model loading declined", + "buildSystems.model.phase.stopped": "Model operation ended without applying a result", + "buildSystems.model.phase.failed": "Model loading failed", + "buildSystems.model.phase.cancelFailed": "Cancellation needs attention", "buildSystems.close": "Close", "buildSystems.refresh": "Reload", "buildSystems.manifestRejected": "{plugin}: {message}", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index e01f8eb344..5a0857109f 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9311,6 +9311,36 @@ "settings.declarativePlugins.sources.errorGeneric": "来源操作失败,请重试。", "settings.declarativePlugins.sources.corruptNotice": "已保存的来源列表无法读取。原文件已保留在存储目录旁,列表已按预置来源重建。", "settings.declarativePlugins.sources.retry": "重试", + "buildSystems.model.title": "构建模型", + "buildSystems.model.load": "加载模型…", + "buildSystems.model.reload": "重新加载模型…", + "buildSystems.model.cancel": "取消加载模型", + "buildSystems.model.retryCancel": "重试取消", + "buildSystems.model.reviewTitle": "确认加载构建模型", + "buildSystems.model.approve": "同意并加载", + "buildSystems.model.decline": "拒绝", + "buildSystems.model.plugin": "插件", + "buildSystems.model.buildSystem": "构建系统", + "buildSystems.model.workspace": "工作区", + "buildSystems.model.instance": "实例", + "buildSystems.model.operation": "操作", + "buildSystems.model.generation": "代次", + "buildSystems.model.projectCode": "此操作可能执行项目中的代码。", + "buildSystems.model.noProjectCode": "准备好的计划未声明执行项目代码。", + "buildSystems.model.network": "此操作可能访问网络。", + "buildSystems.model.noNetwork": "准备好的计划未声明访问网络。", + "buildSystems.model.failed": "加载模型失败:{message}。请检查工具或项目配置后重试。", + "buildSystems.model.cancelFailed": "取消失败:{message}。请先重试取消,再开始新的加载。", + "buildSystems.model.stderr": "模型加载标准错误输出", + "buildSystems.model.phase.preparing": "正在准备模型确认信息…", + "buildSystems.model.phase.review": "等待确认", + "buildSystems.model.phase.loading": "正在加载模型…", + "buildSystems.model.phase.stopping": "正在取消,等待操作清理完成…", + "buildSystems.model.phase.ready": "模型已加载", + "buildSystems.model.phase.declined": "已拒绝加载模型", + "buildSystems.model.phase.stopped": "模型操作已结束,未应用结果", + "buildSystems.model.phase.failed": "加载模型失败", + "buildSystems.model.phase.cancelFailed": "需要处理取消失败", "buildSystems.close": "关闭", "buildSystems.refresh": "重新加载", "buildSystems.manifestRejected": "{plugin}:{message}", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index f43985297f..143472edb3 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -88424,6 +88424,222 @@ "count": "integer" } }, + { + "id": "buildSystems.model.title", + "description": "Windows command model loading UI: title", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.load", + "description": "Windows command model loading UI: load", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.reload", + "description": "Windows command model loading UI: reload", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.cancel", + "description": "Windows command model loading UI: cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.retryCancel", + "description": "Windows command model loading UI: retryCancel", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.reviewTitle", + "description": "Windows command model loading UI: reviewTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.approve", + "description": "Windows command model loading UI: approve", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.decline", + "description": "Windows command model loading UI: decline", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.plugin", + "description": "Windows command model loading UI: plugin", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.buildSystem", + "description": "Windows command model loading UI: buildSystem", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.workspace", + "description": "Windows command model loading UI: workspace", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.instance", + "description": "Windows command model loading UI: instance", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.operation", + "description": "Windows command model loading UI: operation", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.generation", + "description": "Windows command model loading UI: generation", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.projectCode", + "description": "Windows command model loading UI: projectCode", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.noProjectCode", + "description": "Windows command model loading UI: noProjectCode", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.network", + "description": "Windows command model loading UI: network", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.noNetwork", + "description": "Windows command model loading UI: noNetwork", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.failed", + "description": "Windows command model loading UI: failed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "buildSystems.model.cancelFailed", + "description": "Windows command model loading UI: cancelFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "buildSystems.model.stderr", + "description": "Windows command model loading UI: stderr", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.preparing", + "description": "Windows command model loading UI: phase.preparing", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.review", + "description": "Windows command model loading UI: phase.review", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.loading", + "description": "Windows command model loading UI: phase.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.stopping", + "description": "Windows command model loading UI: phase.stopping", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.ready", + "description": "Windows command model loading UI: phase.ready", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.declined", + "description": "Windows command model loading UI: phase.declined", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.stopped", + "description": "Windows command model loading UI: phase.stopped", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.failed", + "description": "Windows command model loading UI: phase.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.model.phase.cancelFailed", + "description": "Windows command model loading UI: phase.cancelFailed", + "platforms": [ + "windows" + ] + }, { "id": "buildSystems.close", "description": "Windows declarative build UI: buildSystems.close", diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index 461c5b8ab0..226ac4e4e2 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -55,12 +55,17 @@ "windows/tauri/src/features/build-systems/output/components/build-output-log.test.tsx", "windows/tauri/src/features/build-systems/output/components/build-output-problems.test.tsx", "windows/tauri/src/features/build-systems/output/components/build-output-tests.test.tsx", - "windows/tauri/src-tauri/src/main.rs" + "windows/tauri/src-tauri/src/main.rs", + "windows/tauri/src-tauri/src/plugin_host/build/run.rs", + "windows/tauri/src-tauri/src/plugin_host/build/output.rs", + "windows/tauri/src-tauri/src/plugin_host/build/run_tests.rs", + "windows/tauri/src/features/build-systems/components/build-model-controls.tsx", + "windows/tauri/src/features/build-systems/components/build-model-controls.test.tsx" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "Windows 内部 registry 已接入 Core 静态检测、选项、同步指纹和两阶段命令模型加载;三个模型命令及原生窗口关闭清理已注册。模型执行绑定窗口/工作区代次/operation 和包摘要,审批前不探测或执行项目代码,取消/超时回收进程树,冻结工具路径与已验证 runtime 的环境;失败输出有界脱敏。模型文件属于当前操作的可写 plugin-state,不进入发行资源或跨工作树缓存。通用输出组件已提供注入 port、有界 UTF-8 日志/诊断/测试明细和合法源码跳转,旧代次、迟到取消/订阅不得污染新运行。任务/测试执行、真实输出事件、网页桥、语言导入及 Provider 仍待后续接线;buildSystems/executableResolution/buildProviders 保持关闭,整体 partial/pending。", - "verification": "运行 WindowsRust 逐项计时,检查真实 Core 的模型准备/审批、候选解析、runtime JAVA_HOME、输出文件、取消/期限/清理和错误脱敏回归。前端分别运行 build-systems service/store/pane 及 output 六个文件计时测试,覆盖完整身份、UTF-8/长行预算、并发、终态、异步订阅清理、取消失败和诊断行列/路径。原生仍需验证 Wrapper 同意前零执行、模型加载/取消/关闭、任务与测试输出、诊断跳转和多窗口资源回收;组件测试不能代替真实运行。macOS 按现有 build-plans fixture 与构建宿主流程执行;两平台的完整 Provider、网页和语言导入证据按各自行记录。" + "notes": "Windows 内部 registry 已接入 Core 静态检测、选项、同步指纹和两阶段模型加载;显式加载/重载 UI 展示原生身份与风险并等待同意,支持拒绝、取消失败重试和竞争请求失效。原生 task/test 两阶段入口已复用 Core taskPlan/testPlan、工具/runtime 解析与 Job Object 执行器,输出按完整身份仅发送所属窗口,Core 解码诊断与测试报告后发布单一终态;清理失败保留占用,过期/未加载命令模型不得用于模型相关执行,报告起始时间在 spawn 前冻结。构建 output 组件仍为注入 port,生产 task/test 前端接线、应用/服务运行、网页桥、语言导入和 Provider 待后续;buildSystems/executableResolution/buildProviders 保持关闭,整体 partial/pending。模型临时文件仍为操作独占的可写 plugin-state,不进入发行资源或复用缓存。", + "verification": "Windows:运行 WindowsRust 逐项计时,覆盖真实 Core 计划/解码、审批前零执行、候选/runtime 环境、失败输出、双窗口、模型/包变更、取消与终态排序、期限和清理失败;报告必须包含快速报告不漏收、未加载/过期模型拒绝执行。前端按文件独立运行 service/store/pane/model-controls 和 output 测试,覆盖同意/拒绝、旧审批、竞争请求、取消失败重试、完整身份、UTF-8/长行预算及源码跳转。Release/WebView2 仍需在能力开放前验证命令模型、任务/测试实际输出、诊断跳转与多窗口清理;单元和组件回归不提升产品 verified。macOS 继续按现有 fixture 和构建宿主流程验证,Provider/网页/导入另行记录。" } diff --git a/shared/platform-feature-matrix/features/plugin-marketplace.json b/shared/platform-feature-matrix/features/plugin-marketplace.json index a064c5781b..a75980c9d1 100644 --- a/shared/platform-feature-matrix/features/plugin-marketplace.json +++ b/shared/platform-feature-matrix/features/plugin-marketplace.json @@ -71,12 +71,16 @@ "windows/tauri/src/features/plugins/marketplace/marketplace-session.test.ts", "windows/tauri/src/features/plugins/marketplace/marketplace-section.test.tsx", "windows/tauri/src/features/plugins/marketplace/marketplace-host-service.ts", - "windows/tauri/src/features/plugins/marketplace/marketplace-host-service.test.ts" + "windows/tauri/src/features/plugins/marketplace/marketplace-host-service.test.ts", + "scripts/plugin-host-acceptance/marketplace_acceptance.py", + "scripts/plugin-host-acceptance/marketplace_fixture.py", + "scripts/plugin-host-acceptance/marketplace_acceptance_test.py", + "scripts/test-windows-declarative-plugin-host.ps1" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "Windows 已接通原生市场目录、索引刷新、下载准备、人工确认与取消,以及设置页市场浏览、搜索、按来源显示和安装后刷新。Core 决定平台/API 可用性与归档;原生安装记录决定更新来源,磁盘、内置、未知来源和跨源同 ID 条目不能被接管。所有市场安装与更新均显示真实来源、完整权限和程序声明并要求明确确认;官方徽标只来自固定官方源与签名校验的原生结论。窗口关闭、源变更和页面卸载回收操作,迟到准备与确认不能污染新会话。Windows 产品端到端验收尚待新构建运行,保持 partial/pending。", + "notes": "Windows 已接通原生市场目录、索引刷新、下载准备、人工确认与取消,以及设置页市场浏览、搜索、按来源显示和安装后刷新。Core 决定平台/API 可用性与归档;原生安装记录决定更新来源,磁盘、内置、未知来源和跨源同 ID 条目不能被接管。所有市场安装与更新均显示真实来源、完整权限和程序声明并要求明确确认;官方徽标只来自固定官方源与签名校验的原生结论。窗口关闭、源变更和页面卸载回收操作,迟到准备与确认不能污染新会话。Windows 产品端到端验收尚待新构建运行,保持 partial/pending。 已准备隔离原生市场验收入口、真实 HTTPS fixture 与完整程序声明/来源固定/取消/重启/更新驱动,并接入启动器可选参数;驱动单元与 DOM 测试不等于真实安装通过。受控交易仍需已有可信 HTTPS 证书及可达主机,官方发布者签名验收需正式 Windows 包;不修改 TLS/签名信任或系统信任库。", "verification": "Windows:逐文件运行 declarative-marketplace、marketplace-types/session/section/host-service 和源管理测试,覆盖来源固定更新、权限扩张、取消先于登记、坏摘要、签名、staging/记录回滚、切源与并行审批竞态;Rust WindowsRust 计时套件覆盖真实 Core 与 M1 事务。运行 typecheck、i18n、资源复用拒绝路由和矩阵门禁。原生复验需在隔离用户目录完成添加源→刷新→搜索→完整审批→拒绝/取消→安装→重启→同源更新→停用/删除,并确认临时文件、所有子进程和发行资源哈希。macOS 按其既有市场/磁盘安装、来源、摘要、审批与资源只读测试执行;没有目标平台实机证据不得提升 verified。" } diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index eed3cfe61a..cf525a429b 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -345,6 +345,8 @@ fn main() { plugin_host::build::declarative_plugins_build_prepare_model, plugin_host::build::declarative_plugins_build_load_model, plugin_host::build::declarative_plugins_build_cancel_operation, + plugin_host::build::declarative_plugins_build_prepare_run, + plugin_host::build::declarative_plugins_build_execute_run, plugin_host::web::declarative_plugins_close_workspace_pages, plugin_host::web::declarative_plugins_web_windows, plugin_host::web::declarative_plugins_open_web_window, diff --git a/windows/tauri/src-tauri/src/plugin_host/build.rs b/windows/tauri/src-tauri/src/plugin_host/build.rs index 77af869ebc..825c2ea041 100644 --- a/windows/tauri/src-tauri/src/plugin_host/build.rs +++ b/windows/tauri/src-tauri/src/plugin_host/build.rs @@ -22,12 +22,18 @@ use tauri::{AppHandle, Manager, WebviewWindow}; mod host; mod operation; +mod output; mod process; +mod run; +#[cfg(test)] +mod run_tests; #[cfg(test)] mod tests; pub use host::BuildModelLoadResult; pub use operation::BuildModelReview; +pub use output::BuildRunTerminal; +pub use run::{BuildRunReview, BuildRunSelection}; /// Registry shared by every window; entries are keyed per window label. #[derive(Default)] @@ -220,6 +226,7 @@ pub async fn declarative_plugins_build_refresh( workspace_root: String, ) -> Result { let window_label = window.label().to_string(); + let _suspension = suspend_build_execution(&app, &window_label)?; stop_build_operations(&app, |operation| { operation.context.window_label == window_label }) @@ -251,6 +258,7 @@ pub async fn declarative_plugins_build_set_option( value: Option, ) -> Result { let window_label = window.label().to_string(); + let _suspension = suspend_build_execution(&app, &window_label)?; stop_build_operations(&app, |operation| { operation.context.window_label == window_label }) @@ -310,6 +318,11 @@ pub async fn declarative_plugins_build_plugins_changed( plugin_id: String, ) -> Result<(), String> { let window_label = window.label().to_string(); + app.state::() + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .drop_plugin(&plugin_id); stop_build_operations(&app, |operation| operation.context.plugin_id == plugin_id).await?; run_build_task(app, move |registry, _source, _persistence| { registry.drop_plugin(&plugin_id); @@ -328,6 +341,23 @@ pub async fn declarative_plugins_build_close_workspace( workspace_root: String, ) -> Result<(), String> { let window_label = window.label().to_string(); + // A late close for a detached workspace must not suspend a newer one. + let _suspension = { + let state = app.state::(); + let mut registry = state + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")?; + if registry.view(&window_label).workspace_root == workspace_root { + registry.suspend_execution(&window_label); + Some(BuildExecutionSuspension { + app: app.clone(), + window: window_label.clone(), + }) + } else { + None + } + }; stop_build_operations(&app, |operation| { operation.context.window_label == window_label && operation.context.workspace_root == workspace_root @@ -380,6 +410,41 @@ pub async fn declarative_plugins_build_cancel_operation( host::cancel(app, window.label().to_string(), operation_id).await } +/// Prepares a declared task/test against the current native model. This call +/// never probes a wrapper or starts a project process. +#[tauri::command] +pub async fn declarative_plugins_build_prepare_run( + app: AppHandle, + window: WebviewWindow, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, + selection: BuildRunSelection, +) -> Result { + host::prepare_run( + app, + window.label().to_string(), + workspace_root, + plugin_id, + build_system_id, + root, + selection, + ) + .await +} + +/// Confirms exactly one prepared operation. Subscribe to this window's +/// `declarative-build-output` before calling; terminal is the last event. +#[tauri::command] +pub async fn declarative_plugins_build_execute_run( + app: AppHandle, + window: WebviewWindow, + operation_id: String, +) -> Result { + host::execute_run(app, window.label().to_string(), operation_id).await +} + async fn stop_build_operations( app: &AppHandle, predicate: impl Fn(&operation::ModelOperation) -> bool, @@ -391,9 +456,38 @@ async fn stop_build_operations( host::drain(operations).await } +struct BuildExecutionSuspension { + app: AppHandle, + window: String, +} +impl Drop for BuildExecutionSuspension { + fn drop(&mut self) { + let state = self.app.state::(); + if let Ok(mut registry) = state.registry.lock() { + registry.resume_execution(&self.window); + }; + } +} + +fn suspend_build_execution( + app: &AppHandle, + window_label: &str, +) -> Result { + app.state::() + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .suspend_execution(window_label); + Ok(BuildExecutionSuspension { + app: app.clone(), + window: window_label.into(), + }) +} + /// Main's native window-destruction hook must call this even when pagehide /// cannot run. Native cancellation also observes a missing host window. pub async fn close_build_window(app: AppHandle, window_label: String) -> Result<(), String> { + let _suspension = suspend_build_execution(&app, &window_label)?; stop_build_operations(&app, |operation| { operation.context.window_label == window_label }) diff --git a/windows/tauri/src-tauri/src/plugin_host/build/host.rs b/windows/tauri/src-tauri/src/plugin_host/build/host.rs index bc36f3b2f3..cafebb7bf6 100644 --- a/windows/tauri/src-tauri/src/plugin_host/build/host.rs +++ b/windows/tauri/src-tauri/src/plugin_host/build/host.rs @@ -3,12 +3,15 @@ use super::super::build_registry::BuildModelContext; use super::super::build_resolver::{RuntimeExecutableInput, WindowsBuildToolResolver}; +use super::output::{BuildOutputSink, BuildRunTerminal}; +use super::run::{prepare_run_plan, run_review, BuildRunReview, BuildRunSelection}; use super::{operation::*, process::*, *}; use serde_json::json; use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; +use tauri::Emitter; const OPERATION_TIMEOUT: Duration = Duration::from_secs(180); @@ -199,6 +202,15 @@ pub(super) async fn load( window_label: String, operation_id: String, ) -> Result { + if app + .state::() + .operations + .get(&window_label, &operation_id)? + .run + .is_some() + { + return Err("plugin.build.invalidPlan".into()); + } // Claim before spawning so duplicate confirmations cannot start two jobs. let running = app .state::() @@ -279,3 +291,163 @@ pub(super) async fn drain(operations: Vec>) -> Result<(), St } Ok(()) } + +struct WindowBuildOutput(AppHandle); +impl BuildOutputSink for WindowBuildOutput { + fn emit(&self, window_label: &str, event: Value) -> Result<(), String> { + // Only the initiating product window receives these events, never + // plugin pages or other product windows sharing the application. + self.0 + .emit_to(window_label, "declarative-build-output", event) + .map_err(|_| "plugin.build.outputUnavailable".into()) + } +} + +pub(super) async fn prepare_run( + app: AppHandle, + window_label: String, + workspace_root: String, + plugin_id: String, + build_system_id: String, + root: String, + selection: BuildRunSelection, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if window_label != "main" && !window_label.starts_with("workspace-") { + return Err("plugin.build.staleContext".into()); + } + let state = app.state::(); + let context = state + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .model_context( + &window_label, + &workspace_root, + &plugin_id, + &build_system_id, + &root, + )?; + let _lease = app + .state::() + .acquire(&plugin_id)?; + let package = verified_identity(&app, &context)?; + let id = next_operation_id(); + let state_directory = store::plugin_state_directory(&app, &plugin_id)?; + let run = prepare_run_plan( + &HostCoreExecutor, + &context, + &selection, + &package.root, + &state_directory.join(store::workspace_state_key(&workspace_root)), + &id, + Arc::new(WindowBuildOutput(app.clone())), + )?; + let settings = super::super::settings::load_layers(&app, &plugin_id, &workspace_root)?; + let invocation = HostCoreExecutor.execute( + "build.invocationPlan", + json!({ + "manifest":context.package.manifest, "buildSystemID":build_system_id, + "request":{"root":root,"platform":"windows","settings":settings.global}, + }), + )?; + core_ok(&invocation)?; + let working_directory = WindowsBuildToolResolver::instance_directory( + &invocation["invocation"], + &PathBuf::from(&workspace_root), + ) + .map_err(|error| format!("{}: {error}", error.code()))?; + let resolver = WindowsBuildToolResolver::from_current_process(BTreeMap::new()) + .freeze_discovery() + .map_err(|error| error.to_string())?; + // Shared operation ownership, busy slot, runtime resolver and guards. + // No model output allocation is used for a task/test operation. + let plan = ModelCommandPlan { + arguments: run.plan.arguments.clone(), + environment: run.plan.environment.clone(), + working_directory: run.plan.working_directory.clone(), + output: "stdout".into(), + executes_project_code: true, + may_use_network: true, + }; + let mut operation = ModelOperation::new( + id.clone(), + context, + plan, + invocation["invocation"].clone(), + resolver, + PathBuf::new(), + working_directory, + ); + operation.run = Some(run); + let review = run_review(&operation)?; + verified_identity(&app, &operation.context)?; + state.operations.insert(operation)?; + let operation = state.operations.get(&window_label, &id)?; + if !state + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")? + .context_is_current(&operation.context) + { + operation.cancel(); + return Err("plugin.build.staleContext".into()); + } + Ok(review) + }) + .await + .map_err(|_| "plugin.build.processFailed".to_string())? +} + +pub(super) async fn execute_run( + app: AppHandle, + window_label: String, + operation_id: String, +) -> Result { + let operation = app + .state::() + .operations + .get(&window_label, &operation_id)?; + if operation.run.is_none() { + return Err("plugin.build.invalidPlan".into()); + } + let running = app + .state::() + .operations + .claim(&window_label, &operation_id)?; + tauri::async_runtime::spawn_blocking(move || { + let operation = &running.0; + let guard = HostGuard { + app: &app, + operation, + deadline: Instant::now() + Duration::from_secs(660), + }; + let mut lease = None; + let result = (|| { + guard.checkpoint()?; + lease = Some( + app.state::() + .acquire(&operation.context.plugin_id)?, + ); + guard.checkpoint()?; + super::run::execute_run(operation, &HostCoreExecutor, &NativeBuildExecutor, &guard) + })(); + if result + .as_ref() + .err() + .is_some_and(|error| error.starts_with("plugin.build.cleanupFailed")) + { + // Preserve occupancy and package mutation barrier on failed cleanup. + operation.retain_failed_cleanup(lease.take()); + return result; + } + let terminal = result.unwrap_or_else(|error| super::run::error_terminal(&error)); + // Keep package mutation blocked through the final identity check and + // publication. Cancellation shares this publication lock. + let completed = operation.complete_run(terminal, || guard.checkpoint()); + drop(lease); + completed + }) + .await + .map_err(|_| "plugin.build.processFailed".to_string())? +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/operation.rs b/windows/tauri/src-tauri/src/plugin_host/build/operation.rs index 19c71916f5..03e785f291 100644 --- a/windows/tauri/src-tauri/src/plugin_host/build/operation.rs +++ b/windows/tauri/src-tauri/src/plugin_host/build/operation.rs @@ -4,7 +4,7 @@ use super::super::build_registry::{BuildCoreExecutor, BuildModelContext}; use super::super::build_resolver::{ - BuildResolutionStep, VersionProbeOutcome, WindowsBuildToolResolver, + BuildResolutionStep, ResolvedBuildTool, VersionProbeOutcome, WindowsBuildToolResolver, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -66,6 +66,7 @@ pub(super) struct ModelOperation { pub resolver: WindowsBuildToolResolver, pub output_file: PathBuf, pub working_directory: PathBuf, + pub run: Option, created: Instant, cancelled: AtomicBool, running: AtomicBool, @@ -93,6 +94,7 @@ impl ModelOperation { resolver, output_file, working_directory, + run: None, created: Instant::now(), cancelled: AtomicBool::new(false), running: AtomicBool::new(false), @@ -143,6 +145,14 @@ impl ModelOperation { // A pending review owns no processes. A claimed operation is finished // only by its RAII owner after native tree cleanup has returned. if !self.running.load(Ordering::Acquire) { + if let Some(run) = &self.run { + // No native owner exists for a cancelled pending review. + let _ = run.events.terminal(&super::output::BuildRunTerminal { + kind: "cancelled".into(), + exit_code: None, + message: Some("plugin.build.cancelled".into()), + }); + } self.finish(); } } @@ -158,6 +168,42 @@ impl ModelOperation { write() } + /// The native owner calls this only after tree cleanup. Order its last + /// checkpoint and terminal event with cancellation, so a cancel accepted + /// before publication cannot be followed by a successful terminal. + pub fn complete_run( + &self, + mut terminal: super::output::BuildRunTerminal, + checkpoint: impl FnOnce() -> Result<(), String>, + ) -> Result { + let _publication = self + .publication + .lock() + .map_err(|_| "build publication mutex poisoned")?; + let checked = if self.cancelled() { + Err("plugin.build.cancelled".into()) + } else { + checkpoint() + }; + if let Err(error) = checked { + terminal.kind = if super::run::error_terminal(&error).kind == "cancelled" { + "cancelled" + } else if terminal.kind == "failedToStart" { + "failedToStart" + } else { + "failed" + } + .into(); + terminal.message = Some(error); + } + self.run + .as_ref() + .ok_or("plugin.build.invalidPlan")? + .events + .terminal(&terminal)?; + Ok(terminal) + } + pub fn start_permit(&self) -> Result, String> { let permit = self .publication @@ -251,8 +297,16 @@ impl BuildOperations { pub fn claim(&self, window: &str, id: &str) -> Result { let operation = self.get(window, id)?; + // Linearize claim with pending-review cancellation. A cancellation + // that wins cannot emit terminal and then have this owner start work. + let permit = operation + .publication + .lock() + .map_err(|_| "build publication mutex poisoned")?; if operation.created.elapsed() >= REVIEW_TIMEOUT { + drop(permit); operation.cancel(); + return Err("plugin.build.cancelled".into()); } if operation.cancelled() || operation.finished() { return Err("plugin.build.cancelled".into()); @@ -267,6 +321,7 @@ impl BuildOperations { // Cancellation may race with the claim. The executor checks it before // any side effect; waiters must now wait for this owner's Drop. operation.finished.store(false, Ordering::Release); + drop(permit); Ok(RunningModelOperation(operation)) } @@ -328,22 +383,58 @@ pub(super) struct ExecutedModel { pub model: Value, pub stderr: String, } +pub(super) struct BuildRunProcessOutput { + pub exit_code: Option, + pub started: bool, + pub failure: Option, + pub started_at_millis: Option, +} pub(super) trait BuildProcessExecutor { fn run( &self, plan: &BuildProcessPlan, guard: &dyn ModelExecutionGuard, ) -> Result; + fn run_command( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + output: &mut dyn FnMut(lithe_git_host::Stream, &[u8]) -> Result<(), String>, + ) -> BuildRunProcessOutput { + let started_at_millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|time| u64::try_from(time.as_millis()).ok()); + match self.run(plan, guard) { + Ok(result) => { + let failure = output(lithe_git_host::Stream::Stdout, &result.stdout) + .and_then(|_| output(lithe_git_host::Stream::Stderr, &result.stderr)) + .err(); + BuildRunProcessOutput { + exit_code: Some(0), + started: true, + failure, + started_at_millis, + } + } + Err(error) => BuildRunProcessOutput { + exit_code: None, + started: false, + failure: Some(error), + started_at_millis: None, + }, + } + } } /// Shared pipeline for real native execution and deterministic executor tests. /// No operation mutates the registry until the caller's final checkpoint. -pub(super) fn execute_model( +pub(super) fn resolve_tool( operation: &ModelOperation, core: &dyn BuildCoreExecutor, executor: &dyn BuildProcessExecutor, guard: &dyn ModelExecutionGuard, -) -> Result { +) -> Result { guard.checkpoint()?; // consent returned; before runtime preparation let resolver = operation .resolver @@ -407,6 +498,16 @@ pub(super) fn execute_model( } } }; + Ok(tool) +} + +pub(super) fn execute_model( + operation: &ModelOperation, + core: &dyn BuildCoreExecutor, + executor: &dyn BuildProcessExecutor, + guard: &dyn ModelExecutionGuard, +) -> Result { + let tool = resolve_tool(operation, core, executor, guard)?; guard.checkpoint()?; // immediately before model command start let output = executor.run( &BuildProcessPlan { diff --git a/windows/tauri/src-tauri/src/plugin_host/build/output.rs b/windows/tauri/src-tauri/src/plugin_host/build/output.rs new file mode 100644 index 0000000000..bcd84ddad4 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/output.rs @@ -0,0 +1,171 @@ +//! Window-scoped ordered events matching output/build-output-types.ts. The +//! native operation is the sole lifecycle owner; no replay buffer or second +//! run registry is maintained here. + +use super::super::build_registry::BuildModelContext; +use serde::Serialize; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +pub(super) const MAX_RUN_OUTPUT: usize = 8 * 1024 * 1024; +const MAX_EVENTS: usize = 16_384; +const MAX_DECODED_EVENT: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BuildRunTerminal { + pub kind: String, + pub exit_code: Option, + /// Stable host category or bounded tool diagnostic, never raw environment. + pub message: Option, +} + +pub(super) trait BuildOutputSink: Send + Sync { + fn emit(&self, window_label: &str, event: Value) -> Result<(), String>; +} + +#[derive(Default)] +struct EventState { + bytes: usize, + events: usize, + terminal: bool, +} + +pub(super) struct RunEvents { + window: String, + identity: Value, + sink: Arc, + state: Mutex, +} + +impl RunEvents { + pub fn new( + context: &BuildModelContext, + operation_id: &str, + sink: Arc, + ) -> Self { + Self { + window: context.window_label.clone(), + identity: json!({ + "operationId": operation_id, "pluginId": context.plugin_id, + "buildSystemId": context.build_system_id, "root": context.root, + "workspaceRoot": context.workspace_root, "generation": context.generation, + }), + sink, + state: Mutex::new(EventState::default()), + } + } + + fn event(&self, payload: Value) -> Value { + let mut event = self.identity.clone(); + event + .as_object_mut() + .expect("routing identity") + .extend(payload.as_object().expect("event").clone()); + event + } + + pub fn chunk(&self, stream: &str, text: &str) -> Result<(), String> { + if text.is_empty() { + return Ok(()); + } + let mut state = self + .state + .lock() + .map_err(|_| "plugin.build.outputUnavailable")?; + if state.terminal { + return Err("plugin.build.staleContext".into()); + } + if state.bytes.saturating_add(text.len()) > MAX_RUN_OUTPUT || state.events >= MAX_EVENTS { + return Err("plugin.build.outputLimit".into()); + } + state.bytes += text.len(); + state.events += 1; + self.sink.emit( + &self.window, + self.event(json!({"type":"chunk", "stream":stream, "text":text})), + ) + } + + pub fn decoded(&self, kind: &str, key: &str, value: Value) -> Result<(), String> { + if serde_json::to_vec(&value) + .map_err(|_| "plugin.build.invalidOutput")? + .len() + > MAX_DECODED_EVENT + { + return Err("plugin.build.outputLimit".into()); + } + let state = self + .state + .lock() + .map_err(|_| "plugin.build.outputUnavailable")?; + if state.terminal { + return Err("plugin.build.staleContext".into()); + } + let mut payload = json!({"type":kind}); + payload[key] = value; + self.sink.emit(&self.window, self.event(payload)) + } + + /// Called only after the executor has drained its complete tree, or for + /// a cancelled review that has never acquired a process. Failed cleanup + /// deliberately leaves the operation occupied and emits no false terminal. + pub fn terminal(&self, terminal: &BuildRunTerminal) -> Result<(), String> { + let mut state = self + .state + .lock() + .map_err(|_| "plugin.build.outputUnavailable")?; + if state.terminal { + return Ok(()); + } + state.terminal = true; + self.sink.emit( + &self.window, + self.event(json!({"type":"terminal", "terminal":terminal})), + ) + } +} + +/// Incremental stream decoding keeps UTF-8 code points intact across native +/// pipe chunks. The pending tail is at most three bytes for valid UTF-8. +#[derive(Default)] +pub(super) struct Utf8Stream { + pending: Vec, +} +impl Utf8Stream { + pub fn push(&mut self, bytes: &[u8]) -> String { + self.pending.extend_from_slice(bytes); + let mut text = String::new(); + loop { + match std::str::from_utf8(&self.pending) { + Ok(valid) => { + text.push_str(valid); + self.pending.clear(); + break; + } + Err(error) => { + let end = error.valid_up_to(); + text.push_str( + std::str::from_utf8(&self.pending[..end]).expect("validated prefix"), + ); + match error.error_len() { + Some(length) => { + text.push('\u{fffd}'); + self.pending.drain(..end + length); + } + None => { + self.pending.drain(..end); + break; + } + } + } + } + } + text + } + pub fn finish(&mut self) -> String { + let text = String::from_utf8_lossy(&self.pending).into_owned(); + self.pending.clear(); + text + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/process.rs b/windows/tauri/src-tauri/src/plugin_host/build/process.rs index 92b08add43..cb636be4d6 100644 --- a/windows/tauri/src-tauri/src/plugin_host/build/process.rs +++ b/windows/tauri/src-tauri/src/plugin_host/build/process.rs @@ -2,8 +2,8 @@ //! tree termination stay in lithe_git_host, shared with the launch path. use super::operation::{ - BuildProcessExecutor, BuildProcessOutput, BuildProcessPlan, ModelExecutionGuard, - MODEL_OUTPUT_LIMIT, + BuildProcessExecutor, BuildProcessOutput, BuildProcessPlan, BuildRunProcessOutput, + ModelExecutionGuard, MODEL_OUTPUT_LIMIT, }; use std::cell::{Cell, RefCell}; use std::path::{Path, PathBuf}; @@ -39,96 +39,188 @@ pub(super) fn process_failure(reason: &str, stdout: &[u8], stderr: &[u8]) -> Str } pub(super) struct NativeBuildExecutor; + +/// `lithe_git_host` invokes its started hook after the child is resumed. +/// Freeze report freshness before entering that runner, so even an immediate +/// child report belongs to this run. The hook only records successful spawn. +pub(super) struct ProcessStart { + before_spawn_millis: Option, + started: Cell, +} +impl ProcessStart { + pub fn capture(clock: impl FnOnce() -> Option) -> Self { + Self { + before_spawn_millis: clock(), + started: Cell::new(false), + } + } + pub fn mark_started(&self) { + self.started.set(true); + } + pub fn started(&self) -> bool { + self.started.get() + } + pub fn started_at_millis(&self) -> Option { + self.started().then_some(self.before_spawn_millis).flatten() + } +} + impl BuildProcessExecutor for NativeBuildExecutor { fn run( &self, plan: &BuildProcessPlan, guard: &dyn ModelExecutionGuard, ) -> Result { - guard.checkpoint()?; - if !plan.executable.is_absolute() || !plan.directory.is_absolute() { - return Err("plugin.build.invalidPlan: executable and cwd must be absolute".into()); - } - let mut command = std::process::Command::new(&plan.executable); - command - .args(&plan.arguments) - .current_dir(&plan.directory) - .env_clear() - .envs(&plan.environment); - let deadline = Instant::now() + plan.timeout; - let stdout_bytes = Cell::new(0usize); - let stderr_bytes = Cell::new(0usize); - let output_file_invalid = Cell::new(false); - let exceeded = - || stdout_bytes.get() > plan.output_limit || stderr_bytes.get() > plan.output_limit; - let start_permit = RefCell::new(Some(guard.start_permit()?)); - let outcome = lithe_git_host::run( - &mut command, - None, - || { - if let Some(path) = &plan.output_file { - output_file_invalid.set(validate_output_file(path).is_err()); - } - exceeded() - || output_file_invalid.get() - || Instant::now() >= deadline - || guard.cancelled() - }, - || { - drop(start_permit.borrow_mut().take()); - }, - |stream, bytes| { - let count = match stream { - lithe_git_host::Stream::Stdout => &stdout_bytes, - lithe_git_host::Stream::Stderr => &stderr_bytes, - }; - count.set(count.get().saturating_add(bytes.len())); - }, - ); - drop(start_permit); // spawn failure has no started hook - // The shared executor returns only after bounded process-tree cleanup. - // Never publish success merely because the parent process exited. - if matches!( - outcome.failure.as_ref(), - Some(lithe_git_host::Failure::Cleanup) - ) { - return Err("plugin.build.cleanupFailed: process tree did not drain".into()); - } - guard.checkpoint()?; - if exceeded() - || output_file_invalid.get() - || matches!( - outcome.failure.as_ref(), - Some(lithe_git_host::Failure::OutputLimit) - ) - { - return Err("plugin.build.outputLimit".into()); - } - if Instant::now() >= deadline { - return Err("plugin.build.timeout".into()); + let (output, result) = capture_process(plan, guard, &mut |_, _| Ok(())); + if let Some(error) = result.failure { + return Err(error); } - if let Some(failure) = outcome.failure { + if result.exit_code != Some(0) { return Err(process_failure( - &format!("{failure:?}"), - &outcome.stdout, - &outcome.stderr, + "unsuccessful exit", + &output.stdout, + &output.stderr, )); } - if outcome.status.is_none_or(|status| !status.success()) { - return Err(process_failure( - &format!("unsuccessful exit ({:?})", outcome.status), - &outcome.stdout, - &outcome.stderr, - )); - } - if let Some(path) = &plan.output_file { - validate_output_file(path)?; - } - Ok(BuildProcessOutput { + Ok(output) + } + + fn run_command( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + output: &mut dyn FnMut(lithe_git_host::Stream, &[u8]) -> Result<(), String>, + ) -> BuildRunProcessOutput { + capture_process(plan, guard, output).1 + } +} + +/// Both model probes and explicit runs use the same frozen argv/environment, +/// budgets, native Job Object and cleanup/error precedence. +fn capture_process( + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + output: &mut dyn FnMut(lithe_git_host::Stream, &[u8]) -> Result<(), String>, +) -> (BuildProcessOutput, BuildRunProcessOutput) { + let empty = |error: String| { + ( + BuildProcessOutput { + stdout: vec![], + stderr: vec![], + }, + BuildRunProcessOutput { + exit_code: None, + started: false, + failure: Some(error), + started_at_millis: None, + }, + ) + }; + if let Err(error) = guard.checkpoint() { + return empty(error); + } + if !plan.executable.is_absolute() || !plan.directory.is_absolute() { + return empty("plugin.build.invalidPlan".into()); + } + let mut command = std::process::Command::new(&plan.executable); + command + .args(&plan.arguments) + .current_dir(&plan.directory) + .env_clear() + .envs(&plan.environment); + let deadline = Instant::now() + plan.timeout; + let stdout_bytes = Cell::new(0usize); + let stderr_bytes = Cell::new(0usize); + let output_file_invalid = Cell::new(false); + let exceeded = + || stdout_bytes.get() > plan.output_limit || stderr_bytes.get() > plan.output_limit; + let start_permit = match guard.start_permit() { + Ok(permit) => RefCell::new(Some(permit)), + Err(error) => return empty(error), + }; + let start = ProcessStart::capture(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|time| u64::try_from(time.as_millis()).ok()) + }); + let stream_failure = RefCell::new(None::); + let outcome = lithe_git_host::run( + &mut command, + None, + || { + if let Some(path) = &plan.output_file { + output_file_invalid.set(validate_output_file(path).is_err()); + } + exceeded() + || stream_failure.borrow().is_some() + || output_file_invalid.get() + || Instant::now() >= deadline + || guard.cancelled() + }, + || { + start.mark_started(); + drop(start_permit.borrow_mut().take()); + }, + |stream, bytes| { + let count = match stream { + lithe_git_host::Stream::Stdout => &stdout_bytes, + lithe_git_host::Stream::Stderr => &stderr_bytes, + }; + count.set(count.get().saturating_add(bytes.len())); + if !exceeded() && stream_failure.borrow().is_none() { + if let Err(error) = output(stream, bytes) { + *stream_failure.borrow_mut() = Some(error); + } + } + }, + ); + // Spawn failure has no started hook. Never publish success merely because + // the parent process exited. + drop(start_permit); + let failure = if matches!( + outcome.failure.as_ref(), + Some(lithe_git_host::Failure::Cleanup) + ) { + Some("plugin.build.cleanupFailed".into()) + } else if let Some(error) = stream_failure.into_inner() { + Some(error) + } else if exceeded() + || output_file_invalid.get() + || matches!( + outcome.failure.as_ref(), + Some(lithe_git_host::Failure::OutputLimit) + ) + { + Some("plugin.build.outputLimit".into()) + } else if Instant::now() >= deadline { + Some("plugin.build.timeout".into()) + } else if let Err(error) = guard.checkpoint() { + Some(error) + } else if let Some(failure) = outcome.failure { + Some(process_failure( + &format!("{failure:?}"), + &outcome.stdout, + &outcome.stderr, + )) + } else if let Some(path) = &plan.output_file { + validate_output_file(path).err() + } else { + None + }; + let result = BuildRunProcessOutput { + exit_code: outcome.status.and_then(|status| status.code()), + started: start.started(), + failure, + started_at_millis: start.started_at_millis(), + }; + ( + BuildProcessOutput { stdout: outcome.stdout, stderr: outcome.stderr, - }) - } + }, + result, + ) } pub(super) fn validate_output_file(path: &Path) -> Result<(), String> { diff --git a/windows/tauri/src-tauri/src/plugin_host/build/run.rs b/windows/tauri/src-tauri/src/plugin_host/build/run.rs new file mode 100644 index 0000000000..e0aa4f511e --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/run.rs @@ -0,0 +1,377 @@ +//! Declared task/test selection and execution. Requests name native model IDs; +//! Core alone expands argv, chooses report globs and decodes tool output. + +use super::super::build_registry::{BuildCoreExecutor, BuildModelContext}; +use super::operation::*; +use super::output::*; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum BuildRunKind { + Task, + Test, +} + +/// Only selection IDs and declared user arguments cross the IPC boundary. +/// Executables, entity properties, cwd, env, reports and permissions never do. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BuildRunSelection { + pub kind: BuildRunKind, + pub task_id: Option, + pub project_id: Option, + pub target_id: Option, + pub model_task_id: Option, + pub suite: Option, + pub case: Option, + #[serde(default)] + pub user_arguments: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RunCommandPlan { + pub arguments: Vec, + pub environment: BTreeMap, + pub working_directory: String, + pub execution: String, + #[serde(default)] + pub results: Value, +} + +pub(super) struct PreparedRun { + pub kind: BuildRunKind, + pub plan: RunCommandPlan, + pub title: String, + pub decode_request: Value, + pub diagnostics: bool, + pub events: RunEvents, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildRunReview { + #[serde(flatten)] + pub operation: BuildModelReview, + pub title: String, + pub kind: String, +} + +fn entity(model: &Value, collection: &str, id: &Option) -> Result, String> { + id.as_ref() + .map(|id| { + model[collection] + .as_array() + .and_then(|items| { + items + .iter() + .find(|item| item["id"].as_str() == Some(id.as_str())) + }) + .cloned() + .ok_or_else(|| "plugin.build.invalidSelection".into()) + }) + .transpose() +} + +/// Pure selection/plan construction; package/state paths are native inputs. +pub(super) fn prepare_run_plan( + core: &dyn BuildCoreExecutor, + context: &BuildModelContext, + selection: &BuildRunSelection, + plugin_directory: &Path, + state_directory: &Path, + id: &str, + sink: Arc, +) -> Result { + let system = context.package.manifest["buildSystems"] + .as_array() + .and_then(|systems| { + systems + .iter() + .find(|system| system["id"] == context.build_system_id) + }) + .ok_or("plugin.build.invalidSelection")?; + // A model-derived selection must not use an old project/target/task. A + // workspace test with reports also needs every current model project: + // report globs are project-relative, so missing projects can hide failures. + // Workspace commands that do not depend on a model remain available. + let needs_model = selection.project_id.is_some() + || selection.target_id.is_some() + || selection.model_task_id.is_some() + || (selection.kind == BuildRunKind::Test + && !system["test"]["results"].is_null() + && selection.project_id.is_none()); + if needs_model && !system["model"].is_null() && (context.model.is_null() || context.model_stale) + { + return Err("plugin.build.modelReloadRequired".into()); + } + let project = entity(&context.model, "projects", &selection.project_id)?; + let target = entity(&context.model, "targets", &selection.target_id)?; + let model_task = entity(&context.model, "tasks", &selection.model_task_id)?; + for child in [&target, &model_task].into_iter().flatten() { + if project + .as_ref() + .is_none_or(|project| child["projectID"] != project["id"]) + { + return Err("plugin.build.invalidSelection".into()); + } + } + let projects: Vec<_> = context.model["projects"] + .as_array() + .into_iter() + .flatten() + .filter_map(|project| project["path"].as_str()) + .collect(); + let mut request = json!({"root":context.root, "options":context.options, + "pluginDirectory":plugin_directory, "workspaceStateDirectory":state_directory, + "project":project, "target":target, "userArguments":selection.user_arguments}); + let (command, title) = match selection.kind { + BuildRunKind::Task => { + if selection.suite.is_some() || selection.case.is_some() { + return Err("plugin.build.invalidSelection".into()); + } + let task_id = selection + .task_id + .as_ref() + .ok_or("plugin.build.invalidSelection")?; + let task = system["tasks"] + .as_array() + .and_then(|tasks| { + tasks + .iter() + .find(|task| task["id"].as_str() == Some(task_id.as_str())) + }) + .ok_or("plugin.build.invalidSelection")?; + if model_task + .as_ref() + .is_some_and(|model_task| model_task["template"].as_str() != Some(task_id.as_str())) + { + return Err("plugin.build.invalidSelection".into()); + } + request["task"] = json!(task_id); + request["modelTask"] = json!(model_task); + ( + "build.taskPlan", + task["displayName"].as_str().unwrap_or(task_id).to_string(), + ) + } + BuildRunKind::Test => { + if selection.task_id.is_some() || selection.model_task_id.is_some() { + return Err("plugin.build.invalidSelection".into()); + } + request["suite"] = json!(selection.suite); + request["case"] = json!(selection.case); + request["projects"] = json!(projects); + ("build.testPlan", context.build_system_id.clone()) + } + }; + let response = core.execute( + command, + json!({"manifest":context.package.manifest, + "buildSystemID":context.build_system_id, "request":request}), + )?; + core_ok(&response)?; + let plan: RunCommandPlan = + serde_json::from_value(response["plan"].clone()).map_err(|_| "plugin.build.invalidPlan")?; + // Long-running service/application plans require Run ownership and are not + // allowed to become an unbounded background task in this batch. + if plan.execution != "task" || plan.working_directory != context.root { + return Err("plugin.build.unavailable".into()); + } + let decode_request = json!({"workspaceRoot":context.workspace_root, "root":context.root, + "project":project.as_ref().map(|project| &project["path"]), "projects":projects, "suite":selection.suite}); + Ok(PreparedRun { + kind: selection.kind.clone(), + plan, + title, + decode_request, + diagnostics: !system["diagnostics"].is_null(), + events: RunEvents::new(context, id, sink), + }) +} + +pub(super) fn run_review(operation: &ModelOperation) -> Result { + let run = operation.run.as_ref().ok_or("plugin.build.invalidPlan")?; + Ok(BuildRunReview { + operation: operation.review(), + title: run.title.clone(), + kind: match run.kind { + BuildRunKind::Task => "task", + BuildRunKind::Test => "test", + } + .into(), + }) +} + +pub(super) fn error_terminal(error: &str) -> BuildRunTerminal { + let cancelled = [ + "plugin.build.cancelled", + "plugin.build.staleContext", + "plugin.build.packageChanged", + ] + .iter() + .any(|prefix| error.starts_with(prefix)); + BuildRunTerminal { + kind: if cancelled { + "cancelled" + } else { + "failedToStart" + } + .into(), + exit_code: None, + message: Some(error.to_string()), + } +} + +/// Returns one terminal only after native cleanup and Core decoding. Failure +/// to confirm cleanup is an error, not a terminal claiming the process ended. +pub(super) fn execute_run( + operation: &ModelOperation, + core: &dyn BuildCoreExecutor, + executor: &dyn BuildProcessExecutor, + guard: &dyn ModelExecutionGuard, +) -> Result { + let run = operation.run.as_ref().ok_or("plugin.build.invalidPlan")?; + guard.checkpoint()?; + let tool = resolve_tool(operation, core, executor, guard)?; + guard.checkpoint()?; + let mut stdout = Utf8Stream::default(); + let mut stderr = Utf8Stream::default(); + let mut diagnostic_stdout = String::new(); + let mut diagnostic_stderr = String::new(); + let mut bytes = 0usize; + let mut on_output = |stream: lithe_git_host::Stream, chunk: &[u8]| -> Result<(), String> { + bytes = bytes.saturating_add(chunk.len()); + if bytes > MAX_RUN_OUTPUT { + return Err("plugin.build.outputLimit".into()); + } + let (decoder, text, name) = match stream { + lithe_git_host::Stream::Stdout => (&mut stdout, &mut diagnostic_stdout, "stdout"), + lithe_git_host::Stream::Stderr => (&mut stderr, &mut diagnostic_stderr, "stderr"), + }; + let decoded = decoder.push(chunk); + text.push_str(&decoded); + operation.publish(|| { + if guard.cancelled() { + return Err("plugin.build.cancelled".into()); + } + run.events.chunk(name, &decoded) + }) + }; + let process = executor.run_command( + &BuildProcessPlan { + executable: tool.executable, + arguments: run.plan.arguments.clone(), + environment: tool.environment, + directory: operation.working_directory.clone(), + timeout: Duration::from_secs(600), + output_limit: MAX_RUN_OUTPUT, + output_file: None, + }, + guard, + &mut on_output, + ); + drop(on_output); + if process + .failure + .as_deref() + .is_some_and(|error| error.starts_with("plugin.build.cleanupFailed")) + { + return Err(process.failure.unwrap()); + } + let mut terminal = BuildRunTerminal { + kind: if !process.started { + "failedToStart" + } else if process.failure.is_some() || process.exit_code != Some(0) { + "failed" + } else { + "succeeded" + } + .into(), + exit_code: process.exit_code, + message: process.failure, + }; + let decoded = (|| -> Result<(), String> { + guard.checkpoint()?; + if !guard.cancelled() { + for (name, decoder, text) in [ + ("stdout", &mut stdout, &mut diagnostic_stdout), + ("stderr", &mut stderr, &mut diagnostic_stderr), + ] { + let tail = decoder.finish(); + text.push_str(&tail); + operation.publish(|| { + guard.checkpoint()?; + run.events.chunk(name, &tail) + })?; + } + } + guard.checkpoint()?; + // Keep independent streams separate so a partial stdout line cannot + // splice into a stderr diagnostic. Both streams remain in arrival order + // in chunk events; Core sees complete per-stream diagnostic lines. + let output = format!("{diagnostic_stdout}\n{diagnostic_stderr}"); + let decode = |command: &str, request: Value| -> Result { + guard.checkpoint()?; + let response = core.execute( + command, + json!({"manifest":operation.context.package.manifest, + "buildSystemID":operation.context.build_system_id, "request":request}), + )?; + core_ok(&response)?; + guard.checkpoint()?; + Ok(response) + }; + if run.diagnostics { + let response = decode( + "build.decodeDiagnostics", + json!({"workspaceRoot":operation.context.workspace_root, + "root":operation.context.root, "output":output}), + )?; + operation.publish(|| { + guard.checkpoint()?; + run.events.decoded( + "diagnostics", + "diagnostics", + response["diagnostics"].clone(), + ) + })?; + } + if run.kind == BuildRunKind::Test && !run.plan.results.is_null() && process.started { + let mut request = run.decode_request.clone(); + request["output"] = json!(output); + request["notBeforeMillis"] = json!(process + .started_at_millis + .ok_or("plugin.build.invalidPlan")?); + let response = decode("build.decodeTestResults", request)?; + if response["results"]["success"] == false { + terminal.kind = "failed".into(); + } + operation.publish(|| { + guard.checkpoint()?; + run.events + .decoded("testResults", "results", response["results"].clone()) + })?; + } + guard.checkpoint()?; + Ok(()) + })(); + if let Err(error) = decoded { + terminal.kind = if error_terminal(&error).kind == "cancelled" { + "cancelled" + } else if process.started { + "failed" + } else { + "failedToStart" + } + .into(); + terminal.message = Some(error); + } + Ok(terminal) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/run_tests.rs b/windows/tauri/src-tauri/src/plugin_host/build/run_tests.rs new file mode 100644 index 0000000000..29a75a69d4 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/run_tests.rs @@ -0,0 +1,971 @@ +//! Real Core task/test plans and output decoders with a controllable process +//! owner. No test starts a developer tool or waits for real time. + +use super::super::build_registry::*; +use super::super::build_resolver::WindowsBuildToolResolver; +use super::operation::*; +use super::output::*; +use super::process::ProcessStart; +use super::run::*; +use serde_json::{json, Value}; +use std::cell::{Cell, RefCell}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, UNIX_EPOCH}; + +const RUN_START: u64 = 1_700_000_000_000; +struct Core; +impl BuildCoreExecutor for Core { + fn execute(&self, command: &str, payload: Value) -> Result { + super::super::scan::core_execute(command, payload) + } +} +struct Fixture { + root: PathBuf, +} +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!("lithe run tests {}", next_operation_id())); + std::fs::create_dir_all(&root).expect("fixture"); + let fixture = Self { root }; + fixture.file( + "Cargo.toml", + "[package]\nname=\"fixture\"\nversion=\"0.1.0\"\nedition=\"2021\"\n", + ); + fixture.file("src/main.rs", "fn main() {}\n"); + fixture.file("tools/cargo.exe", "placeholder"); + fixture + } + fn file(&self, relative: &str, text: &str) -> PathBuf { + let path = self.root.join(relative); + std::fs::create_dir_all(path.parent().expect("parent")).expect("directory"); + std::fs::write(&path, text).expect("file"); + path + } + fn path(&self) -> String { + self.root.to_string_lossy().into_owned() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} +fn manifest() -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json"); + serde_json::from_str(&std::fs::read_to_string(path).expect("fixture manifest")).expect("JSON") +} +struct Package(BuildPackageInput); +impl BuildPackageSource for Package { + fn accepted_packages(&self) -> Vec { + vec![self.0.clone()] + } + fn package(&self, id: &str) -> Option { + (id == self.0.plugin_id).then(|| self.0.clone()) + } +} +struct Options; +impl BuildOptionsPersistence for Options { + fn load(&self, _: &str) -> Result { + Ok(StoredBuildOptions::default()) + } + fn save(&self, _: &str, _: &StoredBuildOptions) -> Result<(), String> { + Ok(()) + } +} +fn registry( + fixture: &Fixture, + manifest: Value, + window: &str, +) -> (BuildRegistry, Package, BuildModelContext) { + let package = Package(BuildPackageInput { + plugin_id: manifest["id"].as_str().expect("ID").into(), + version: "0.1.0".into(), + manifest, + content_identity: "frozen-bytes".into(), + }); + let mut registry = BuildRegistry::new(); + let detected = registry + .refresh(&Core, &package, &Options, window, &fixture.path()) + .expect("passive detect"); + let context = registry + .model_context(window, &fixture.path(), &package.0.plugin_id, "cargo", ".") + .unwrap_or_else(|error| { + panic!( + "context: {error}; detection: {}", + serde_json::to_string(&detected).expect("detection diagnostic") + ) + }); + (registry, package, context) +} +#[derive(Default)] +struct Sink(Mutex>); +impl BuildOutputSink for Sink { + fn emit(&self, window: &str, event: Value) -> Result<(), String> { + self.0.lock().expect("events").push((window.into(), event)); + Ok(()) + } +} +fn selection(context: &BuildModelContext, kind: BuildRunKind) -> BuildRunSelection { + BuildRunSelection { + task_id: (kind == BuildRunKind::Task).then(|| "build".into()), + kind, + project_id: context.model["projects"][0]["id"] + .as_str() + .map(str::to_string), + target_id: None, + model_task_id: None, + suite: None, + case: None, + user_arguments: vec![], + } +} +fn operation( + fixture: &Fixture, + context: BuildModelContext, + selection: &BuildRunSelection, + sink: Arc, +) -> ModelOperation { + let id = next_operation_id(); + let run = prepare_run_plan( + &Core, + &context, + selection, + &fixture.root, + &fixture.root, + &id, + sink, + ) + .expect("Core task/test plan"); + let response=Core.execute("build.invocationPlan",json!({"manifest":context.package.manifest,"buildSystemID":"cargo","request":{"root":".","platform":"windows"}})).expect("invocation"); + core_ok(&response).expect("valid invocation"); + let resolver = WindowsBuildToolResolver::with_environment( + BTreeMap::from([( + "PATH".into(), + fixture.root.join("tools").to_string_lossy().into_owned(), + )]), + BTreeMap::new(), + ); + let plan = ModelCommandPlan { + arguments: run.plan.arguments.clone(), + environment: run.plan.environment.clone(), + working_directory: ".".into(), + output: "stdout".into(), + executes_project_code: true, + may_use_network: true, + }; + let cwd = WindowsBuildToolResolver::instance_directory(&response["invocation"], &fixture.root) + .expect("cwd"); + let mut operation = ModelOperation::new( + id, + context, + plan, + response["invocation"].clone(), + resolver, + PathBuf::new(), + cwd, + ); + operation.run = Some(run); + operation +} +struct Guard Result<(), String>>(F); +impl Result<(), String>> ModelExecutionGuard for Guard { + fn checkpoint(&self) -> Result<(), String> { + self.0() + } + fn cancelled(&self) -> bool { + self.0().is_err() + } +} +type OutputCallback<'a> = dyn FnMut(lithe_git_host::Stream, &[u8]) -> Result<(), String> + 'a; +struct Executor) -> BuildRunProcessOutput> { + action: F, + plans: RefCell>, +} +impl) -> BuildRunProcessOutput> + BuildProcessExecutor for Executor +{ + fn run( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + ) -> Result { + guard.checkpoint()?; + self.plans.borrow_mut().push(plan.clone()); + assert_eq!(plan.arguments, ["--version"]); + Ok(BuildProcessOutput { + stdout: b"cargo 1.80.1".to_vec(), + stderr: vec![], + }) + } + fn run_command( + &self, + plan: &BuildProcessPlan, + guard: &dyn ModelExecutionGuard, + output: &mut OutputCallback<'_>, + ) -> BuildRunProcessOutput { + if let Err(error) = guard.checkpoint() { + return result(None, Some(&error), false); + } + self.plans.borrow_mut().push(plan.clone()); + (self.action)(plan, output) + } +} +fn executor) -> BuildRunProcessOutput>( + action: F, +) -> Executor { + Executor { + action, + plans: RefCell::new(vec![]), + } +} +fn result(code: Option, failure: Option<&str>, started: bool) -> BuildRunProcessOutput { + BuildRunProcessOutput { + exit_code: code, + started, + failure: failure.map(str::to_string), + started_at_millis: started.then_some(RUN_START), + } +} +fn finish(operation: &ModelOperation, terminal: &BuildRunTerminal) { + operation + .complete_run(terminal.clone(), || Ok(())) + .expect("terminal"); +} + +#[test] +fn successful_task_uses_core_argv_same_environment_and_ordered_window_events() { + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operation = operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + ); + assert!( + sink.0.lock().expect("events").is_empty(), + "prepare never runs/probes" + ); + let executor = executor(|plan, output| { + assert_eq!(plan.arguments, ["build", "--message-format=short"]); + output(lithe_git_host::Stream::Stdout, b"compiling\n").expect("stdout"); + output( + lithe_git_host::Stream::Stderr, + b"src/main.rs:2:3: warning: fixture\n", + ) + .expect("stderr"); + result(Some(0), None, true) + }); + let terminal = execute_run(&operation, &Core, &executor, &Guard(|| Ok(()))).expect("run"); + finish(&operation, &terminal); + finish(&operation, &terminal); + assert_eq!(terminal.kind, "succeeded"); + let plans = executor.plans.borrow(); + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].environment, plans[1].environment); + assert_eq!(plans[0].executable, plans[1].executable); + assert!(plans[1].executable.is_absolute()); + let events = sink.0.lock().expect("events"); + assert_eq!(events.len(), 4); + assert_eq!( + events + .iter() + .map(|(_, e)| e["type"].as_str().expect("type")) + .collect::>(), + ["chunk", "chunk", "diagnostics", "terminal"] + ); + for (window, event) in events.iter() { + assert_eq!(window, "main"); + assert_eq!(event["operationId"], operation.id); + assert_eq!(event["pluginId"], context.plugin_id); + assert_eq!(event["buildSystemId"], "cargo"); + assert_eq!(event["workspaceRoot"], fixture.path()); + assert_eq!(event["root"], "."); + assert_eq!(event["generation"], context.generation); + } + assert_eq!(events[2].1["diagnostics"][0]["path"], "src/main.rs"); + assert_eq!(events[2].1["diagnostics"][0]["line"], 2); + assert!(operation + .run + .as_ref() + .expect("run") + .events + .chunk("stdout", "late") + .is_err()); +} + +#[test] +fn unsuccessful_task_retains_output_and_core_diagnostics_before_terminal() { + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operation = operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + ); + let executor = executor(|_, output| { + output( + lithe_git_host::Stream::Stderr, + b"src/main.rs:7:2: error: compile failed\n", + ) + .expect("output"); + result(Some(101), None, true) + }); + let terminal = + execute_run(&operation, &Core, &executor, &Guard(|| Ok(()))).expect("drained failure"); + finish(&operation, &terminal); + assert_eq!(terminal.kind, "failed"); + assert_eq!(terminal.exit_code, Some(101)); + let events = sink.0.lock().expect("events"); + assert_eq!(events[1].1["diagnostics"][0]["severity"], "error"); + assert_eq!(events.last().expect("terminal").1["type"], "terminal"); +} + +#[test] +fn test_reports_are_decoded_by_core_and_older_reports_are_excluded() { + let fixture = Fixture::new(); + let mut declaration = manifest(); + declaration["buildSystems"][0]["test"]["results"] = + json!({"decoder":"junitXml","reports":["reports/*.xml"]}); + let (_, _, context) = registry(&fixture, declaration, "main"); + let sink = Arc::new(Sink::default()); + let operation = operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Test), + sink.clone(), + ); + let executor = executor(|_, output| { + for (name, time, status) in [ + ("old", RUN_START - 1000, ""), + ("current", RUN_START + 1000, ""), + ] { + let path=fixture.file(&format!("reports/{name}.xml"),&format!("{status}")); + std::fs::OpenOptions::new() + .write(true) + .open(path) + .expect("report") + .set_modified(UNIX_EPOCH + Duration::from_millis(time)) + .expect("controlled report clock"); + } + output(lithe_git_host::Stream::Stdout, b"tests finished\n").expect("output"); + result(Some(0), None, true) + }); + let terminal = + execute_run(&operation, &Core, &executor, &Guard(|| Ok(()))).expect("decoded tests"); + finish(&operation, &terminal); + assert_eq!( + terminal.kind, "failed", + "test failures override a zero process exit" + ); + let events = sink.0.lock().expect("events"); + let results = events + .iter() + .find(|(_, event)| event["type"] == "testResults") + .expect("results"); + assert_eq!(results.1["results"]["summary"]["total"], 1); + assert_eq!(results.1["results"]["tests"][0]["case"], "current"); +} + +#[test] +fn a_report_written_before_the_started_hook_belongs_to_the_current_run() { + let fixture = Fixture::new(); + let mut declaration = manifest(); + declaration["buildSystems"][0]["test"]["results"] = + json!({"decoder":"junitXml","reports":["reports/*.xml"]}); + let (_, _, context) = registry(&fixture, declaration, "main"); + let sink = Arc::new(Sink::default()); + let operation = operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Test), + sink.clone(), + ); + let executor = executor(|_, _| { + let clock = Cell::new(RUN_START); + let start = ProcessStart::capture(|| Some(clock.get())); + assert!(!start.started()); + assert_eq!(start.started_at_millis(), None, "no spawn, no report time"); + // The child writes before its native owner gets the started callback. + // Advance a controlled clock instead of relying on scheduler timing. + let report = fixture.file( + "reports/fast.xml", + "", + ); + std::fs::OpenOptions::new() + .write(true) + .open(report) + .expect("report") + .set_modified(UNIX_EPOCH + Duration::from_millis(clock.get())) + .expect("controlled report clock"); + clock.set(RUN_START + 1000); + start.mark_started(); + assert_eq!(start.started_at_millis(), Some(RUN_START)); + BuildRunProcessOutput { + exit_code: Some(0), + started: start.started(), + failure: None, + started_at_millis: start.started_at_millis(), + } + }); + let terminal = execute_run(&operation, &Core, &executor, &Guard(|| Ok(()))) + .expect("decode the immediate report"); + assert_eq!( + terminal.kind, "failed", + "the early failure cannot disappear" + ); + let events = sink.0.lock().expect("events"); + let results = events + .iter() + .find(|(_, event)| event["type"] == "testResults") + .expect("results"); + assert_eq!(results.1["results"]["summary"]["total"], 1); + assert_eq!(results.1["results"]["summary"]["failed"], 1); +} + +#[test] +fn workspace_reports_and_model_selections_require_a_current_declared_model() { + let fixture = Fixture::new(); + let mut declaration = manifest(); + declaration["buildSystems"][0]["test"]["scopes"] = json!(["workspace", "project", "target"]); + declaration["buildSystems"][0]["tasks"][0]["scopes"] = json!(["workspace", "project"]); + declaration["buildSystems"][0]["test"]["results"] = + json!({"decoder":"junitXml","reports":["reports/*.xml"]}); + let (_, _, static_context) = registry(&fixture, declaration.clone(), "main"); + let mut selected = selection(&static_context, BuildRunKind::Test); + selected.project_id = None; + let prepare = |context: &BuildModelContext, selected: &BuildRunSelection| { + prepare_run_plan( + &Core, + context, + selected, + &fixture.root, + &fixture.root, + "model-validity", + Arc::new(Sink::default()), + ) + }; + assert!( + prepare(&static_context, &selected).is_ok(), + "fresh static model" + ); + + declaration["buildSystems"][0]["model"] = json!({ + "source":"command", "arguments":["metadata"], "decoder":"litheBuildModel", + "executesProjectCode":false, "mayUseNetwork":false, + }); + let (mut registry, package, missing) = registry(&fixture, declaration, "main"); + assert!( + missing.model.is_null(), + "detection does not execute the model" + ); + assert_eq!( + prepare(&missing, &selected).err().as_deref(), + Some("plugin.build.modelReloadRequired") + ); + let before = registry + .model_fingerprint(&Core, &missing) + .expect("fingerprint"); + registry + .apply_command_model(&Core, &missing, static_context.model.clone(), &before) + .expect("load a model already decoded by Core"); + let loaded = registry + .model_context("main", &fixture.path(), &missing.plugin_id, "cargo", ".") + .expect("loaded context"); + assert!(prepare(&loaded, &selected).is_ok(), "loaded command model"); + fixture.file( + "Cargo.toml", + "[package]\nname=\"changed\"\nversion=\"0.1.0\"\n", + ); + registry + .sync_check( + &Core, + &package, + "main", + &fixture.path(), + &missing.plugin_id, + "cargo", + ".", + ) + .expect("mark model stale"); + assert!( + !registry.context_is_current(&loaded), + "old approval is invalid" + ); + let stale = registry + .model_context("main", &fixture.path(), &missing.plugin_id, "cargo", ".") + .expect("stale context remains reloadable"); + assert!(stale.model_stale); + assert_eq!( + prepare(&stale, &selected).err().as_deref(), + Some("plugin.build.modelReloadRequired") + ); + for kind in [BuildRunKind::Task, BuildRunKind::Test] { + let selected = selection(&stale, kind); + assert_eq!( + prepare(&stale, &selected).err().as_deref(), + Some("plugin.build.modelReloadRequired"), + "selected model entities must be current" + ); + } + let mut independent_task = selection(&stale, BuildRunKind::Task); + independent_task.project_id = None; + assert!( + prepare(&stale, &independent_task).is_ok(), + "a workspace task with no model selection does not require a model" + ); +} + +#[test] +fn workspace_reports_without_a_model_declaration_remain_available() { + let fixture = Fixture::new(); + let mut declaration = manifest(); + declaration["buildSystems"][0] + .as_object_mut() + .expect("system") + .remove("model"); + declaration["buildSystems"][0]["options"] + .as_array_mut() + .expect("options") + .retain(|option| option["id"] != "features"); + declaration["buildSystems"][0]["run"] = json!([]); + declaration["buildSystems"][0]["test"]["scopes"] = json!(["workspace"]); + declaration["buildSystems"][0]["test"]["targetTags"] = json!([]); + declaration["buildSystems"][0]["test"]["filters"] = json!([]); + declaration["buildSystems"][0]["test"]["arguments"] = json!(["test", "${optionArguments}"]); + declaration["buildSystems"][0]["test"]["results"] = + json!({"decoder":"junitXml","reports":["reports/*.xml"]}); + let (_, _, context) = registry(&fixture, declaration, "main"); + assert!(context.model.is_null()); + let selected = selection(&context, BuildRunKind::Test); + assert!(selected.project_id.is_none()); + assert!(prepare_run_plan( + &Core, + &context, + &selected, + &fixture.root, + &fixture.root, + "no-model-needed", + Arc::new(Sink::default()), + ) + .is_ok()); +} + +#[test] +fn pending_cancel_and_duplicate_confirmation_never_release_an_active_slot_early() { + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operations = BuildOperations::default(); + let first = operations + .insert(operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + )) + .expect("prepare"); + let pending = operations + .get("main", &first.operation_id) + .expect("pending"); + pending.cancel(); + pending.cancel(); + assert!(operations.claim("main", &first.operation_id).is_err()); + assert_eq!(sink.0.lock().expect("events").len(), 1); + let second = operations + .insert(operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + )) + .expect("new"); + let running = operations + .claim("main", &second.operation_id) + .expect("claim"); + assert!(operations.claim("main", &second.operation_id).is_err()); + running.0.cancel(); + assert!(!running.0.finished()); + assert!(operations + .insert(operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone() + )) + .is_err()); + drop(running); + assert!(operations + .insert(operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink + )) + .is_ok()); +} + +#[test] +fn cancellation_and_terminal_publication_respect_the_winning_order() { + for cancel_first in [true, false] { + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operations = BuildOperations::default(); + let review = operations + .insert(operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + )) + .expect("prepare"); + let running = operations + .claim("main", &review.operation_id) + .expect("claim"); + // Execution has drained but its terminal has not been published. The + // controlled order reproduces both sides of the cancel/finish race. + let executor = executor(|_, _| result(Some(0), None, true)); + let terminal = + execute_run(&running.0, &Core, &executor, &Guard(|| Ok(()))).expect("drained run"); + assert_eq!(terminal.kind, "succeeded"); + if cancel_first { + running.0.cancel(); + } + let terminal = running + .0 + .complete_run(terminal, || Ok(())) + .expect("publish"); + running.0.cancel(); + assert!( + running.0.publish(|| Ok(())).is_err(), + "no publication after cancel" + ); + assert!(!running.0.finished(), "owner still holds the busy slot"); + let owner = running.0.clone(); + drop(running); + assert!(owner.finished()); + let events = sink.0.lock().expect("events"); + let terminals: Vec<_> = events + .iter() + .filter(|(_, event)| event["type"] == "terminal") + .collect(); + assert_eq!(terminals.len(), 1); + assert_eq!( + terminal.kind, + if cancel_first { + "cancelled" + } else { + "succeeded" + } + ); + assert_eq!(terminals[0].1["terminal"]["kind"], terminal.kind); + } +} + +#[test] +fn two_windows_have_independent_slots_and_cannot_confirm_each_others_run() { + let fixture = Fixture::new(); + let (mut registry, package, first) = registry(&fixture, manifest(), "main"); + registry + .refresh( + &Core, + &package, + &Options, + "workspace-second", + &fixture.path(), + ) + .expect("second window"); + let second = registry + .model_context( + "workspace-second", + &fixture.path(), + &first.plugin_id, + "cargo", + ".", + ) + .expect("second context"); + let sink = Arc::new(Sink::default()); + let operations = BuildOperations::default(); + let a = operations + .insert(operation( + &fixture, + first.clone(), + &selection(&first, BuildRunKind::Task), + sink.clone(), + )) + .expect("first"); + let b = operations + .insert(operation( + &fixture, + second.clone(), + &selection(&second, BuildRunKind::Task), + sink.clone(), + )) + .expect("second"); + assert!(operations.claim("main", &b.operation_id).is_err()); + let running = operations + .claim("main", &a.operation_id) + .expect("main claim"); + operations.cancel_matching(|operation| operation.context.window_label == "workspace-second"); + assert!(!running.0.cancelled()); + let events = sink.0.lock().expect("events"); + assert_eq!(events.len(), 1); + assert_eq!(events[0].0, "workspace-second"); + assert_eq!(events[0].1["operationId"], b.operation_id); +} + +#[test] +fn suspension_workspace_return_and_plugin_revocation_invalidate_approval() { + let fixture = Fixture::new(); + let (mut registry, package, context) = registry(&fixture, manifest(), "main"); + registry.suspend_execution("main"); + registry.suspend_execution("main"); + assert!(!registry.context_is_current(&context)); + assert!(registry + .model_context("main", &fixture.path(), &context.plugin_id, "cargo", ".") + .is_err()); + registry.resume_execution("main"); + assert!( + registry + .model_context("main", &fixture.path(), &context.plugin_id, "cargo", ".") + .is_err(), + "overlapping drains remain suspended" + ); + registry.resume_execution("main"); + assert!( + !registry.context_is_current(&context), + "resuming cannot resurrect approval" + ); + let current = registry + .model_context("main", &fixture.path(), &context.plugin_id, "cargo", ".") + .expect("new"); + registry.close_workspace("main", &fixture.path()); + registry + .refresh(&Core, &package, &Options, "main", &fixture.path()) + .expect("return"); + assert!(!registry.context_is_current(¤t)); + let current = registry + .model_context("main", &fixture.path(), &context.plugin_id, "cargo", ".") + .expect("current"); + registry.drop_plugin(&context.plugin_id); + assert!(!registry.context_is_current(¤t)); +} + +#[test] +fn model_replacement_and_window_recreation_never_reuse_an_approved_snapshot() { + let fixture = Fixture::new(); + let (mut registry, package, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operation = operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + ); + let executor = executor(|_, _| result(Some(0), None, true)); + let terminal = execute_run(&operation, &Core, &executor, &Guard(|| Ok(()))).expect("drained"); + let before = registry + .model_fingerprint(&Core, &context) + .expect("fingerprint"); + registry + .apply_command_model(&Core, &context, context.model.clone(), &before) + .expect("replace model even with identical content"); + assert!(!registry.context_is_current(&context)); + let terminal = operation + .complete_run(terminal, || { + if registry.context_is_current(&context) { + Ok(()) + } else { + Err("plugin.build.staleContext".into()) + } + }) + .expect("last checkpoint rejects the replaced model"); + assert_eq!(terminal.kind, "cancelled"); + assert_eq!( + sink.0.lock().expect("events").last().expect("terminal").1["terminal"]["kind"], + "cancelled" + ); + let current = registry + .model_context("main", &fixture.path(), &context.plugin_id, "cargo", ".") + .expect("new model context"); + registry.close_window("main"); + registry + .refresh(&Core, &package, &Options, "main", &fixture.path()) + .expect("recreated window"); + assert!(!registry.context_is_current(¤t)); +} + +#[test] +fn timeout_cancel_output_limit_start_failure_and_cleanup_failure_have_distinct_outcomes() { + for (code, started, expected) in [ + ("plugin.build.timeout", true, "failed"), + ("plugin.build.cancelled", true, "cancelled"), + ("plugin.build.outputLimit", true, "failed"), + ("plugin.build.processFailed", false, "failedToStart"), + ("plugin.build.cleanupFailed", true, "occupied"), + ] { + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operations = BuildOperations::default(); + let review = operations + .insert(operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + )) + .expect("prepare"); + let running = operations + .claim("main", &review.operation_id) + .expect("claim"); + let cancelled = Cell::new(false); + let executor = executor(|_, _| { + cancelled.set(code == "plugin.build.cancelled"); + result(None, Some(code), started) + }); + let guard = Guard(|| { + if cancelled.get() { + Err("plugin.build.cancelled".into()) + } else { + Ok(()) + } + }); + let terminal = execute_run(&running.0, &Core, &executor, &guard); + if expected == "occupied" { + assert_eq!(terminal.err().as_deref(), Some(code)); + running.0.retain_failed_cleanup(()); + let control = running.0.clone(); + drop(running); + assert!(!control.finished()); + assert!(sink.0.lock().expect("events").is_empty()); + } else { + let terminal = terminal.expect("drained"); + assert_eq!(terminal.kind, expected); + finish(&running.0, &terminal); + drop(running); + assert_eq!( + sink.0 + .lock() + .expect("events") + .iter() + .filter(|(_, event)| event["type"] == "terminal") + .count(), + 1 + ); + } + } +} + +#[test] +fn revoke_during_streaming_drops_late_output_and_decoding() { + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let operation = operation( + &fixture, + context.clone(), + &selection(&context, BuildRunKind::Task), + sink.clone(), + ); + let revoked = Cell::new(false); + let executor = executor(|_, output| { + output(lithe_git_host::Stream::Stdout, b"early\n").expect("early"); + revoked.set(true); + assert!(output(lithe_git_host::Stream::Stderr, b"late\n").is_err()); + result(None, Some("plugin.build.cancelled"), true) + }); + let terminal = execute_run( + &operation, + &Core, + &executor, + &Guard(|| { + if revoked.get() { + Err("plugin.build.packageChanged".into()) + } else { + Ok(()) + } + }), + ) + .expect("cancel"); + finish(&operation, &terminal); + let events = sink.0.lock().expect("events"); + assert_eq!(events.len(), 2); + assert_eq!(events[0].1["text"], "early\n"); + assert_eq!(terminal.kind, "cancelled"); +} + +#[test] +fn selection_refuses_injected_commands_entity_paths_reports_and_unknown_properties() { + for field in [ + "executable", + "arguments", + "environment", + "workingDirectory", + "reports", + "permissions", + "url", + "project", + ] { + let mut input = json!({"kind":"task","taskId":"build"}); + input[field] = json!("untrusted"); + assert!( + serde_json::from_value::(input).is_err(), + "{field}" + ); + } + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let mut selected = selection(&context, BuildRunKind::Task); + selected.project_id = Some("../outside".into()); + assert!(prepare_run_plan( + &Core, + &context, + &selected, + &fixture.root, + &fixture.root, + "invalid", + Arc::new(Sink::default()) + ) + .is_err()); +} + +#[test] +fn output_budget_and_split_utf8_are_bounded_without_corrupting_characters() { + let mut stream = Utf8Stream::default(); + let bytes = "中文🙂".as_bytes(); + let mut text = String::new(); + for byte in bytes { + text.push_str(&stream.push(&[*byte])); + } + text.push_str(&stream.finish()); + assert_eq!(text, "中文🙂"); + let fixture = Fixture::new(); + let (_, _, context) = registry(&fixture, manifest(), "main"); + let sink = Arc::new(Sink::default()); + let events = RunEvents::new(&context, "bounded", sink.clone()); + let chunk = "x".repeat(8192); + for _ in 0..MAX_RUN_OUTPUT / 8192 { + events.chunk("stdout", &chunk).expect("within budget"); + } + assert!(events.chunk("stdout", "overflow").is_err()); + let terminal = BuildRunTerminal { + kind: "failed".into(), + exit_code: None, + message: Some("plugin.build.outputLimit".into()), + }; + events + .terminal(&terminal) + .expect("terminal survives output budget"); + assert_eq!( + sink.0.lock().expect("events").last().expect("last").1["type"], + "terminal" + ); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build_registry.rs b/windows/tauri/src-tauri/src/plugin_host/build_registry.rs index a7c608ddf3..b68abd13c7 100644 --- a/windows/tauri/src-tauri/src/plugin_host/build_registry.rs +++ b/windows/tauri/src-tauri/src/plugin_host/build_registry.rs @@ -72,6 +72,7 @@ pub trait BuildOptionsPersistence: Send + Sync { /// One detected build-system instance with its host-side session state. #[derive(Debug, Clone)] struct BuildSession { + revision: u64, plugin_id: String, build_system_id: String, root: String, @@ -98,6 +99,7 @@ struct BuildSession { #[derive(Debug, Default)] struct WindowState { + execution_suspended: usize, workspace_root: Option, generation: u64, /// Keyed by (plugin ID, build system ID, instance root). @@ -132,6 +134,9 @@ pub(crate) struct BuildModelContext { pub root: String, pub package: BuildPackageInput, pub options: BTreeMap, + pub model: Value, + pub model_stale: bool, + revision: u64, } impl BuildRegistry { @@ -151,7 +156,9 @@ impl BuildRegistry { .windows .get(window_label) .ok_or("plugin.build.staleContext")?; - if window.workspace_root.as_deref() != Some(workspace_root) { + if window.execution_suspended > 0 + || window.workspace_root.as_deref() != Some(workspace_root) + { return Err("plugin.build.staleContext".into()); } let session = window @@ -172,6 +179,9 @@ impl BuildRegistry { content_identity: session.content_identity.clone(), }, options: session.effective_options.clone(), + model: session.instance["model"].clone(), + model_stale: session.stale, + revision: session.revision, }) } @@ -179,7 +189,8 @@ impl BuildRegistry { let Some(window) = self.windows.get(&context.window_label) else { return false; }; - if window.generation != context.generation + if window.execution_suspended > 0 + || window.generation != context.generation || window.workspace_root.as_deref() != Some(context.workspace_root.as_str()) { return false; @@ -193,6 +204,8 @@ impl BuildRegistry { )) .is_some_and(|session| { session.version == context.package.version + && session.revision == context.revision + && session.stale == context.model_stale && session.content_identity == context.package.content_identity && session.manifest == context.package.manifest && session.effective_options == context.options @@ -237,6 +250,7 @@ impl BuildRegistry { &context.options, BTreeSet::new(), )?; + session.revision = previous.revision.saturating_add(1); let after = sync_fingerprint( core, &context.workspace_root, @@ -271,7 +285,27 @@ impl BuildRegistry { } pub(crate) fn close_window(&mut self, window_label: &str) { - self.windows.remove(window_label); + if let Some(window) = self.windows.get_mut(window_label) { + window.sessions.clear(); + window.issues.clear(); + window.workspace_root = None; + window.generation = window.generation.saturating_add(1); + } + } + + /// Invalidate before draining, not after it. A concurrent prepare/confirm + /// cannot obtain a fresh executable snapshot while refresh/close is waiting + /// for an older native owner to release its process tree. + pub(crate) fn suspend_execution(&mut self, window_label: &str) { + let window = self.windows.entry(window_label.into()).or_default(); + window.execution_suspended = window.execution_suspended.saturating_add(1); + window.generation = window.generation.saturating_add(1); + } + + pub(crate) fn resume_execution(&mut self, window_label: &str) { + if let Some(window) = self.windows.get_mut(window_label) { + window.execution_suspended = window.execution_suspended.saturating_sub(1); + } } /// Re-detects every instance of the workspace for one window. A changed @@ -856,6 +890,7 @@ fn resolve_session( }; Ok(BuildSession { + revision: 0, plugin_id: instance["pluginID"] .as_str() .unwrap_or_default() diff --git a/windows/tauri/src/features/build-systems/build-systems-service.test.ts b/windows/tauri/src/features/build-systems/build-systems-service.test.ts index a36eda836c..7ab5d86cbf 100644 --- a/windows/tauri/src/features/build-systems/build-systems-service.test.ts +++ b/windows/tauri/src/features/build-systems/build-systems-service.test.ts @@ -530,6 +530,287 @@ describe("explicit command model operations", () => { mayUseNetwork: true, }; + test("caller disposal before prepare completes cancels its late native slot without approval", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const controller = new AbortController(); + cleanups.push(() => controller.abort()); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const approve = mock(async () => true); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + approve, + {}, + { signal: controller.signal }, + ); + await flushMicrotasks(); + controller.abort(); + prepared.resolve(review); + expect(await pending).toBeNull(); + expect(approve).toHaveBeenCalledTimes(0); + expect( + invokeCalls.filter((row) => row.command === "declarative_plugins_build_cancel_operation"), + ).toHaveLength(1); + expect(invokeCalls.some((row) => row.command === "declarative_plugins_build_load_model")).toBe( + false, + ); + }); + + for (const invalidation of ["generation", "selection", "option", "disable"] as const) { + test(`${invalidation} invalidation aborts consent without relying on the bootstrap listener`, async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const actions = useBuildSystemsStore.getState().actions; + const initial = cargoView(WORKSPACE_A, 1); + initial.instances.push({ ...initial.instances[0]!, root: "nested" }); + actions.applyView(initial, actions.beginRequest()); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const consent = deferred(); + let signal: AbortSignal | undefined; + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + (_review, input) => { + signal = input; + return consent.promise as Promise; + }, + ); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + if (invalidation === "generation") + actions.applyView({ ...initial, generation: 2 }, actions.beginRequest()); + else if (invalidation === "selection") + actions.selectInstance(`build-system:${catalogEntry.pluginId}:cargo`, "nested"); + else if (invalidation === "option") + actions.applyView( + { + ...initial, + instances: initial.instances.map((row) => ({ ...row, optionFingerprint: "changed" })), + }, + actions.beginRequest(), + ); + else actions.dropPlugin(catalogEntry.pluginId); + expect(signal!.aborted).toBe(true); + consent.resolve(true); + expect(await pending).toBeNull(); + expect( + invokeCalls.some((row) => row.command === "declarative_plugins_build_load_model"), + ).toBe(false); + }); + } + + test("an option request invalidates open consent before its deferred response lands", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const actions = useBuildSystemsStore.getState().actions; + const initial = cargoView(WORKSPACE_A, 1); + actions.applyView(initial, actions.beginRequest()); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const changed = deferCommand("declarative_plugins_build_set_option"); + const consent = deferred(); + let signal: AbortSignal | undefined; + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + (_review, input) => { + signal = input; + return consent.promise as Promise; + }, + ); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + const edit = setBuildOption(catalogEntry.pluginId, "cargo", ".", "release", true); + // Invalidate at request initiation, while native has returned no new + // fingerprint/generation that could otherwise close this old review. + expect(signal!.aborted).toBe(true); + consent.resolve(true); + expect(await pending).toBeNull(); + expect(invokeCalls.some((row) => row.command === "declarative_plugins_build_load_model")).toBe( + false, + ); + changed.resolve({ + ...initial, + instances: initial.instances.map((row) => ({ ...row, optionFingerprint: "changed" })), + }); + await edit; + expect( + useBuildSystemsStore.getState().windows[`build-system:${catalogEntry.pluginId}:cargo`]! + .instances[0]!.optionFingerprint, + ).toBe("changed"); + }); + + test("a review with the wrong identity is cancelled without asking for consent", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const approve = mock(async () => true); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + approve, + ); + const failed = pending.catch((error) => error); + await flushMicrotasks(); + prepared.resolve({ ...review, root: "foreign" }); + expect(String(await failed)).toContain("staleContext"); + expect(approve).toHaveBeenCalledTimes(0); + expect( + invokeCalls.some((row) => row.command === "declarative_plugins_build_cancel_operation"), + ).toBe(true); + }); + + test("approval exceptions release the review and report the failure", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => { + throw new Error("fixture consent failure"); + }, + ); + const failure = pending.catch((error) => error); + await flushMicrotasks(); + prepared.resolve(review); + expect(String(await failure)).toContain("fixture consent failure"); + expect(invokeCalls.some((row) => row.command === "declarative_plugins_build_load_model")).toBe( + false, + ); + expect( + invokeCalls.filter((row) => row.command === "declarative_plugins_build_cancel_operation"), + ).toHaveLength(1); + }); + + test("a result with the wrong operation cannot mark the model loaded", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const loaded = deferCommand("declarative_plugins_build_load_model"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => true, + ); + const failure = pending.catch((error) => error); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + loaded.resolve({ + operationId: "wrong-operation", + view: cargoView(WORKSPACE_A, 1), + stderr: "late", + }); + expect(String(await failure)).toContain("staleContext"); + expect(useBuildSystemsStore.getState().workspaceRoot).toBeNull(); + }); + + test("cancellation failure is surfaced, retry remains possible, and a late load cannot apply", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const loaded = deferCommand("declarative_plugins_build_load_model"); + const cancelled = deferCommand("declarative_plugins_build_cancel_operation"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => true, + ); + const outcome = pending.catch((error) => error); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + const stop = buildSystemsService.cancelBuildModel(review.operationId).catch((error) => error); + await flushMicrotasks(); + cancelled.reject(new Error("fixture cancel failure")); + expect(String(await stop)).toContain("fixture cancel failure"); + loaded.resolve({ + operationId: review.operationId, + view: cargoView(WORKSPACE_A, 1), + stderr: "late success", + }); + expect(await outcome).toBeInstanceOf(buildSystemsService.BuildModelCancellationError); + expect(useBuildSystemsStore.getState().workspaceRoot).toBeNull(); + const retried = deferCommand("declarative_plugins_build_cancel_operation"); + const retry = buildSystemsService.cancelBuildModel(review.operationId); + await flushMicrotasks(); + retried.resolve(undefined); + await retry; + expect( + invokeCalls.filter((row) => row.command === "declarative_plugins_build_cancel_operation"), + ).toHaveLength(2); + }); + + test("cancel acknowledgement is awaited and concurrent callers share the owned cleanup", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const loaded = deferCommand("declarative_plugins_build_load_model"); + const cleanup = deferCommand("declarative_plugins_build_cancel_operation"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => true, + ); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + let acknowledged = false; + const stop = buildSystemsService.cancelBuildModel(review.operationId).then(() => { + acknowledged = true; + }); + const second = buildSystemsService.cancelBuildModel(review.operationId); + await flushMicrotasks(); + expect(acknowledged).toBe(false); + expect( + invokeCalls.filter((row) => row.command === "declarative_plugins_build_cancel_operation"), + ).toHaveLength(1); + cleanup.resolve(undefined); + await Promise.all([stop, second]); + expect(acknowledged).toBe(true); + loaded.resolve({ + operationId: review.operationId, + view: cargoView(WORKSPACE_A, 1), + stderr: "late", + }); + expect(await pending).toBeNull(); + }); + + test("Stop joins cleanup already pending after a declined review", async () => { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const prepared = deferCommand("declarative_plugins_build_prepare_model"); + const cleanup = deferCommand("declarative_plugins_build_cancel_operation"); + const pending = buildSystemsService.loadBuildModel( + catalogEntry.pluginId, + "cargo", + ".", + async () => false, + ); + await flushMicrotasks(); + prepared.resolve(review); + await flushMicrotasks(); + const stop = buildSystemsService.cancelBuildModel(review.operationId); + await flushMicrotasks(); + expect( + invokeCalls.filter((row) => row.command === "declarative_plugins_build_cancel_operation"), + ).toHaveLength(1); + cleanup.resolve(undefined); + await stop; + expect(await pending).toBeNull(); + }); + test("only confirmation starts the prepared operation and applies its matching result", async () => { resetState(); activateWorkspace("ws-a", WORKSPACE_A); diff --git a/windows/tauri/src/features/build-systems/build-systems-service.ts b/windows/tauri/src/features/build-systems/build-systems-service.ts index ae3e24949b..fa419d8519 100644 --- a/windows/tauri/src/features/build-systems/build-systems-service.ts +++ b/windows/tauri/src/features/build-systems/build-systems-service.ts @@ -11,6 +11,7 @@ import type { } from "./build-systems-types"; import { BUILD_SYSTEMS_STORE_KEY, + buildSystemViewId, useBuildSystemsStore, type BuildSystemsState, } from "./build-systems-store"; @@ -61,18 +62,54 @@ interface PendingModelLoad { pluginId: string; operationId: string | null; controller: AbortController; + cancellation: Promise | null; + cancelError: unknown; } const pendingModelLoads = new Set(); function cancelModelLoads(predicate: (pending: PendingModelLoad) => boolean): void { for (const pending of pendingModelLoads) { if (!predicate(pending)) continue; - pending.controller.abort(); - if (pending.operationId) { - void invoke("declarative_plugins_build_cancel_operation", { - operationId: pending.operationId, - }).catch(() => {}); + // The owning load awaits the same cleanup in finally and surfaces errors. + void requestModelCancellation(pending).catch(() => {}); + } +} + +/** Cleanup failure keeps the operation available to the UI's Stop retry. */ +export class BuildModelCancellationError extends Error {} + +function requestModelCancellation(pending: PendingModelLoad): Promise { + pending.controller.abort(); + if (!pending.operationId) return Promise.resolve(); + if (pending.cancellation) return pending.cancellation; + pending.cancelError = null; + const cancellation = Promise.resolve().then(() => + invoke("declarative_plugins_build_cancel_operation", { + operationId: pending.operationId, + }), + ); + pending.cancellation = cancellation; + void cancellation.catch((error: unknown) => { + pending.cancelError = error; + pending.cancellation = null; + }); + return cancellation; +} + +async function finishModelLoad(pending: PendingModelLoad, completed: boolean): Promise { + try { + if (!completed && pending.operationId) { + if (pending.cancelError) throw pending.cancelError; + await requestModelCancellation(pending); } + } catch (error) { + // Cleanup failure takes priority: the UI must retain a retryable Stop + // until native confirms that the operation has ended. + throw new BuildModelCancellationError(failureMessage(error)); + } finally { + // Keep cleanup discoverable until it settles so a simultaneous Stop + // joins the owned request rather than dispatching another native cancel. + pendingModelLoads.delete(pending); } } @@ -104,6 +141,7 @@ export async function loadBuildModel( root: string, approve: ApproveBuildModel, runtimeExecutables: Record = {}, + options: { signal?: AbortSignal; onPrepared?: (review: BuildModelReview) => void } = {}, ): Promise { const { store, workspaceId, workspaceRoot, isCurrent } = initiatingStore(); if (!workspaceRoot) return null; @@ -112,12 +150,45 @@ export async function loadBuildModel( pluginId, operationId: null, controller: new AbortController(), + cancellation: null, + cancelError: null, }; pendingModelLoads.add(pending); let request: ReturnType | null = null; let completed = false; + const initial = store.getState(); + let expectedRequestSequence = initial.requestSequence; + const generation = initial.generation; + const revocationEpoch = initial.pluginRevocationEpochs[pluginId] ?? 0; + const windowKey = buildSystemViewId(pluginId, buildSystemId); + const originalWindow = initial.windows[windowKey]; + const fingerprint = originalWindow?.instances.find( + (instance) => instance.root === root, + )?.optionFingerprint; const stillCurrent = () => - !pending.controller.signal.aborted && isCurrent() && !store.getState().revokedPlugins[pluginId]; + !pending.controller.signal.aborted && + isCurrent() && + workspaceRuntimeRegistry.getWorkspace(workspaceId)?.stores.get(BUILD_SYSTEMS_STORE_KEY) === + store && + !store.getState().revokedPlugins[pluginId] && + (store.getState().pluginRevocationEpochs[pluginId] ?? 0) === revocationEpoch && + store.getState().generation === generation && + store.getState().requestSequence === expectedRequestSequence && + (!originalWindow || + (store.getState().windows[windowKey]?.selectedRoot === originalWindow.selectedRoot && + store.getState().windows[windowKey]?.instances.find((instance) => instance.root === root) + ?.optionFingerprint === fingerprint)) && + (!request || store.getState().lastAppliedSequence <= request.sequence); + const abort = () => { + void requestModelCancellation(pending).catch(() => {}); + }; + const checkContext = () => { + if (!completed && !stillCurrent()) abort(); + }; + const releaseStore = store.subscribe(checkContext); + const releaseRegistry = workspaceRuntimeRegistry.subscribe(checkContext); + options.signal?.addEventListener("abort", abort, { once: true }); + if (options.signal?.aborted) abort(); try { const review = await enqueueRegistryOperation(() => stillCurrent() @@ -132,20 +203,40 @@ export async function loadBuildModel( ); if (!review) return null; pending.operationId = review.operationId; + options.onPrepared?.(review); + if ( + review.pluginId !== pluginId || + review.buildSystemId !== buildSystemId || + review.root !== root || + review.workspaceRoot !== workspaceRoot || + (generation !== null && review.generation !== generation) + ) { + throw new Error("plugin.build.staleContext"); + } if ( !stillCurrent() || !(await awaitModelApproval(review, approve, pending.controller.signal)) || !stillCurrent() ) return null; + // Only this operation's own request may advance the captured sequence. + // Competing edits/refreshes invalidate consent before their response lands. + expectedRequestSequence += 1; request = store.getState().actions.beginRequest(); + if (!stillCurrent()) return null; // Execution must not occupy registryTail: refresh/revoke/close must be // able to reach native cancellation while a model process is running. const result = await invoke("declarative_plugins_build_load_model", { operationId: review.operationId, }); + if (!stillCurrent()) return null; + if ( + result.operationId !== review.operationId || + result.view.workspaceRoot !== workspaceRoot || + result.view.generation !== review.generation + ) + throw new Error("plugin.build.staleContext"); completed = true; - if (!stillCurrent() || result.operationId !== review.operationId) return null; store.getState().actions.applyView(result.view, request); return result; } catch (error) { @@ -155,21 +246,20 @@ export async function loadBuildModel( } return null; } finally { - pendingModelLoads.delete(pending); + releaseStore(); + releaseRegistry(); + options.signal?.removeEventListener("abort", abort); pending.controller.abort(); if (request) store.getState().actions.cancelRequest(request); - if (!completed && pending.operationId) { - await invoke("declarative_plugins_build_cancel_operation", { - operationId: pending.operationId, - }); - } + await finishModelLoad(pending, completed); } } export async function cancelBuildModel(operationId: string): Promise { for (const pending of pendingModelLoads) { - if (pending.operationId === operationId) pending.controller.abort(); + if (pending.operationId === operationId) return requestModelCancellation(pending); } + // The existing native contract waits for cleanup, not just acceptance. await invoke("declarative_plugins_build_cancel_operation", { operationId }); } diff --git a/windows/tauri/src/features/build-systems/components/build-model-controls.test.tsx b/windows/tauri/src/features/build-systems/components/build-model-controls.test.tsx new file mode 100644 index 0000000000..1f31d742e0 --- /dev/null +++ b/windows/tauri/src/features/build-systems/components/build-model-controls.test.tsx @@ -0,0 +1,412 @@ +import { afterAll, afterEach, expect, mock, spyOn, test } from "bun:test"; +import { act } from "react"; +import * as events from "@tauri-apps/api/event"; +import * as native from "@/platform/tauri-core"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { buildSystemViewId, useBuildSystemsStore } from "../build-systems-store"; +import { BuildModelCancellationError } from "../build-systems-service"; +import type { BuildModelLoadResult, BuildModelReview, BuildView } from "../build-systems-types"; +import type { BuildModelService } from "./build-model-controls"; + +const restoreDom = installHappyDom(); +const { createRoot } = await import("react-dom/client"); +const { default: BuildSystemPane } = await import("./build-system-pane"); +afterAll(restoreDom); +const cleanups: Array<() => void | Promise> = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); + workspaceRuntimeRegistry.resetForTests(); +}); +function deferred(cleanupValue: T) { + let resolve!: (value: T) => void; + let reject!: (failure: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + cleanups.push(() => resolve(cleanupValue)); + return { promise, resolve, reject }; +} +const PLUGIN = "dev.lithe.fixture.model-command"; +const ROOT = "C:/fixture/model-ui-workspace"; +const VIEW = buildSystemViewId(PLUGIN, "fixture-build"); +const review: BuildModelReview = { + pluginId: PLUGIN, + buildSystemId: "fixture-build", + operationId: "review-operation-1", + workspaceRoot: ROOT, + root: ".", + generation: 3, + executesProjectCode: true, + mayUseNetwork: true, +}; +function view(): BuildView { + return { + generation: 3, + workspaceRoot: ROOT, + issues: [], + instances: [ + { + pluginID: PLUGIN, + buildSystemID: "fixture-build", + displayName: "Fixture", + root: ".", + markers: [], + modelSource: "command", + model: null, + stale: false, + syncDigest: null, + optionFingerprint: "options", + options: [], + }, + ], + }; +} +function apply(next: BuildView) { + const actions = useBuildSystemsStore.getState().actions; + actions.applyView(next, actions.beginRequest()); +} +function fakeService() { + const prepared = deferred(review); + const result = deferred(null); + const declinedCleanup = deferred(undefined); + const controller = new AbortController(); + cleanups.push(() => controller.abort()); + let callerSignal: AbortSignal | undefined; + let executes = 0; + const service: BuildModelService = { + load: mock(async (_plugin, _system, _root, approve, _runtimes, options) => { + callerSignal = options?.signal; + const abort = () => controller.abort(); + callerSignal?.addEventListener("abort", abort, { once: true }); + try { + const nextReview = await prepared.promise; + options?.onPrepared?.(nextReview); + if (callerSignal?.aborted) return null; + const accepted = await approve(nextReview, controller.signal); + if (!accepted || callerSignal?.aborted || controller.signal.aborted) { + await declinedCleanup.promise; + return null; + } + executes += 1; + return await result.promise; + } finally { + callerSignal?.removeEventListener("abort", abort); + } + }), + cancel: mock(async () => { + controller.abort(); + }), + }; + return { + service, + prepared, + result, + declinedCleanup, + controller, + get signal() { + return callerSignal; + }, + get executes() { + return executes; + }, + }; +} +async function mount( + initial = view(), + fake = fakeService(), + language: "en-US" | "zh-CN" = "en-US", +) { + workspaceRuntimeRegistry.activateWorkspace( + { id: "model-ui-tests", name: "Fixture", path: ROOT }, + "ready", + ); + useBuildSystemsStore.getState().actions.reset(); + apply(initial); + // Workspace activation normally prepares stores before rendering consumers. + useBuildSystemsStore.getStore("other-model-workspace"); + const invoke = spyOn(native, "invoke").mockImplementation(async (command: string) => { + if (command === "declarative_plugins_build_catalog") return [] as T; + if (command === "declarative_plugins_build_refresh") return initial as T; + throw new Error(`unexpected native call ${command}`); + }); + const listen = spyOn(events, "listen").mockResolvedValue(() => {}); + cleanups.push(() => { + invoke.mockRestore(); + listen.mockRestore(); + }); + const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previousAct = env.IS_REACT_ACT_ENVIRONMENT; + env.IS_REACT_ACT_ENVIRONMENT = true; + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + cleanups.push(async () => { + try { + await act(async () => root.unmount()); + } finally { + host.remove(); + if (previousAct === undefined) delete env.IS_REACT_ACT_ENVIRONMENT; + else env.IS_REACT_ACT_ENVIRONMENT = previousAct; + } + }); + await act(async () => + root.render( + + {}} modelService={fake.service} /> + , + ), + ); + const button = (text: string) => + [...document.querySelectorAll("button")].find( + (row) => row.textContent === text, + )!; + const start = async () => { + await act(async () => + button( + language === "en-US" + ? initial.instances[0]!.model + ? "Reload model…" + : "Load model…" + : "加载模型…", + ).click(), + ); + }; + const prepare = async (value = review) => { + await act(async () => fake.prepared.resolve(value)); + }; + return { host, root, fake, button, start, prepare, invoke }; +} + +test("opening, detecting and changing instance remain passive; only an explicit load prepares a review", async () => { + const initial = view(); + initial.instances.push({ ...initial.instances[0]!, root: "nested" }); + const { fake, host, start, prepare, button } = await mount(initial); + expect(fake.service.load).toHaveBeenCalledTimes(0); + await act(async () => useBuildSystemsStore.getState().actions.selectInstance(VIEW, "nested")); + expect(fake.service.load).toHaveBeenCalledTimes(0); + await act(async () => useBuildSystemsStore.getState().actions.selectInstance(VIEW, ".")); + await start(); + expect(fake.service.load).toHaveBeenCalledTimes(1); + expect(fake.executes).toBe(0); + expect(host.textContent).toContain("Preparing model review"); + await prepare(); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog).not.toBeNull(); + for (const value of [PLUGIN, "fixture-build", ROOT, "review-operation-1"]) + expect(dialog.textContent).toContain(value); + expect(dialog.textContent).toContain("may execute code"); + expect(dialog.textContent).toContain("may access the network"); + await act(async () => button("Allow and load").click()); + expect(fake.executes).toBe(1); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(host.textContent).toContain("Loading model…"); + await act(async () => + fake.result.resolve({ + operationId: review.operationId, + view: initial, + stderr: "upstream fixture warning", + }), + ); + expect(host.textContent).toContain("Model loaded"); + expect(host.textContent).toContain("upstream fixture warning"); +}); + +test("declining closes consent and waits for native review cleanup without execution", async () => { + const { fake, host, start, prepare, button } = await mount(); + await start(); + await prepare(); + await act(async () => button("Decline").click()); + expect(fake.executes).toBe(0); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(host.textContent).toContain("waiting for operation cleanup"); + await act(async () => fake.declinedCleanup.resolve()); + expect(host.textContent).toContain("Model loading declined"); +}); + +test("duplicate clicks prepare only once and approval can execute only once", async () => { + const { fake, prepare, button } = await mount(); + await act(async () => { + button("Load model…").click(); + button("Load model…").click(); + }); + expect(fake.service.load).toHaveBeenCalledTimes(1); + await prepare(); + const approve = button("Allow and load"); + await act(async () => { + approve.click(); + approve.click(); + }); + expect(fake.executes).toBe(1); + await act(async () => fake.result.resolve(null)); +}); + +test("cancellation failure remains actionable and retry awaits confirmation; late success is discarded", async () => { + const { fake, host, start, prepare, button } = await mount(); + const cancellation = deferred(undefined); + fake.service.cancel = mock(() => cancellation.promise); + await start(); + await prepare(); + await act(async () => button("Allow and load").click()); + await act(async () => button("Cancel model loading").click()); + expect(host.textContent).toContain("waiting for operation cleanup"); + expect(host.textContent).not.toContain("Model loaded"); + await act(async () => cancellation.reject(new Error("fixture cleanup failed"))); + expect(host.querySelector('[role="alert"]')!.textContent).toContain("fixture cleanup failed"); + expect(button("Load model…").disabled).toBe(true); + const retry = deferred(undefined); + fake.service.cancel = mock(() => retry.promise); + await act(async () => button("Retry cancellation").click()); + expect(host.textContent).toContain("waiting for operation cleanup"); + await act(async () => retry.resolve()); + expect(fake.service.cancel).toHaveBeenCalledWith(review.operationId); + expect(fake.signal!.aborted).toBe(true); + await act(async () => + fake.result.resolve({ operationId: review.operationId, view: view(), stderr: "late stderr" }), + ); + expect(host.textContent).not.toContain("Model loaded"); + expect(host.textContent).not.toContain("late stderr"); + expect(button("Load model…").disabled).toBe(false); +}); + +test("cancelling during preparation aborts the caller and a late prepare never opens consent", async () => { + const { fake, host, start, prepare, button } = await mount(); + await start(); + await act(async () => button("Cancel model loading").click()); + expect(fake.signal!.aborted).toBe(true); + expect(host.textContent).toContain("waiting for operation cleanup"); + await prepare(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(fake.executes).toBe(0); +}); + +for (const change of ["instance", "workspace", "generation", "disable", "unmount"] as const) { + test(`consent is closed and cannot execute after ${change} invalidates its owner`, async () => { + const initial = view(); + initial.instances.push({ ...initial.instances[0]!, root: "nested" }); + const { fake, root, start, prepare, button } = await mount(initial); + await start(); + await prepare(); + const staleApprove = button("Allow and load"); + await act(async () => { + if (change === "instance") + useBuildSystemsStore.getState().actions.selectInstance(VIEW, "nested"); + else if (change === "workspace") + workspaceRuntimeRegistry.activateWorkspace( + { id: "other-model-workspace", name: "Other", path: "C:/fixture/other-model" }, + "ready", + ); + else if (change === "generation") apply({ ...initial, generation: 4 }); + else if (change === "disable") useBuildSystemsStore.getState().actions.dropPlugin(PLUGIN); + else root.render(null); + }); + expect(fake.signal!.aborted).toBe(true); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + await act(async () => staleApprove.click()); + await act(async () => fake.declinedCleanup.resolve()); + expect(fake.executes).toBe(0); + }); +} + +test("prepare exceptions show a retryable failure and do not enter load", async () => { + const { fake, host, start, button } = await mount(); + await start(); + await act(async () => fake.prepared.reject(new Error("fixture prepare failure"))); + expect(host.querySelector('[role="alert"]')!.textContent).toContain("fixture prepare failure"); + expect(host.textContent).toContain("Check the tool or project configuration"); + expect(fake.executes).toBe(0); + expect(button("Load model…").disabled).toBe(false); +}); + +test("load errors and cleanup errors cannot display successful loading", async () => { + const { fake, host, start, prepare, button } = await mount(); + await start(); + await prepare(); + await act(async () => button("Allow and load").click()); + await act(async () => + fake.result.reject(new BuildModelCancellationError("fixture native cleanup failed")), + ); + expect(host.querySelector('[role="alert"]')!.textContent).toContain( + "fixture native cleanup failed", + ); + expect(button("Retry cancellation")).toBeDefined(); + expect(host.textContent).not.toContain("Model loaded"); +}); + +test("a failed load enables a fresh review while keeping existing model data", async () => { + const initial = view(); + initial.instances[0]!.model = { projects: [{ name: "Existing model", path: "." }] }; + const { fake, host, start, prepare, button } = await mount(initial); + await start(); + await prepare(); + await act(async () => button("Allow and load").click()); + await act(async () => fake.result.reject(new Error("fixture model command failed"))); + expect(host.querySelector('[role="alert"]')!.textContent).toContain( + "fixture model command failed", + ); + expect(host.textContent).toContain("Existing model"); + expect(button("Reload model…").disabled).toBe(false); +}); + +test("a cancellation failure after late preparation retains the operation ID for Stop retry", async () => { + const fake = fakeService(); + fake.service.load = mock(async (_plugin, _system, _root, _approve, _runtime, options) => { + await fake.prepared.promise; + options?.onPrepared?.(review); + throw new BuildModelCancellationError("late prepare cleanup failed"); + }); + const { host, start, prepare, button } = await mount(view(), fake); + await start(); + await act(async () => button("Cancel model loading").click()); + await prepare(); + expect(host.querySelector('[role="alert"]')!.textContent).toContain( + "late prepare cleanup failed", + ); + await act(async () => button("Retry cancellation").click()); + expect(fake.service.cancel).toHaveBeenCalledWith(review.operationId); + expect(host.textContent).not.toContain("Model loaded"); +}); + +test("an old cancellation callback after switching instances cannot change the new instance UI", async () => { + const initial = view(); + initial.instances.push({ ...initial.instances[0]!, root: "nested" }); + const { fake, host, start, prepare, button } = await mount(initial); + const cancelled = deferred(undefined); + fake.service.cancel = mock(() => cancelled.promise); + await start(); + await prepare(); + await act(async () => button("Allow and load").click()); + await act(async () => button("Cancel model loading").click()); + await act(async () => useBuildSystemsStore.getState().actions.selectInstance(VIEW, "nested")); + await act(async () => cancelled.reject(new Error("old stop error"))); + await act(async () => + fake.result.resolve({ operationId: review.operationId, view: initial, stderr: "old stderr" }), + ); + expect(host.textContent).not.toContain("old stop error"); + expect(host.textContent).not.toContain("old stderr"); + expect(host.querySelector('[role="alert"]')).toBeNull(); + expect(button("Load model…").disabled).toBe(false); +}); + +test("static models retain their projects without a command load entry", async () => { + const initial = view(); + initial.instances[0]!.modelSource = "static"; + initial.instances[0]!.model = { projects: [{ name: "Static app", path: "." }] }; + const { fake, host } = await mount(initial); + expect(host.textContent).toContain("Static app"); + expect(host.textContent).not.toContain("Load model…"); + expect(fake.service.load).toHaveBeenCalledTimes(0); +}); + +test("Chinese consent displays the exact review and its declared risk flags", async () => { + const { start, prepare, button } = await mount(view(), fakeService(), "zh-CN"); + await start(); + await prepare({ ...review, executesProjectCode: false, mayUseNetwork: false }); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain("确认加载构建模型"); + expect(dialog.textContent).toContain("准备好的计划未声明执行项目代码"); + expect(dialog.textContent).toContain("准备好的计划未声明访问网络"); + expect(button("同意并加载")).toBeDefined(); +}); diff --git a/windows/tauri/src/features/build-systems/components/build-model-controls.tsx b/windows/tauri/src/features/build-systems/components/build-model-controls.tsx new file mode 100644 index 0000000000..8e9a7e9178 --- /dev/null +++ b/windows/tauri/src/features/build-systems/components/build-model-controls.tsx @@ -0,0 +1,293 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import AppDialog from "@/ui/dialog"; +import { Spinner } from "@/ui/spinner"; +import { + BuildModelCancellationError, + cancelBuildModel, + loadBuildModel, +} from "../build-systems-service"; +import type { BuildInstanceView, BuildModelReview } from "../build-systems-types"; + +/** Uses only the existing model IPC; execution/output ports are separate owners. */ +export interface BuildModelService { + load: typeof loadBuildModel; + cancel: typeof cancelBuildModel; +} +export const buildModelService: BuildModelService = { + load: loadBuildModel, + cancel: cancelBuildModel, +}; + +type Phase = + | "idle" + | "preparing" + | "review" + | "loading" + | "stopping" + | "ready" + | "declined" + | "stopped" + | "failed" + | "cancelFailed"; +interface Attempt { + controller: AbortController; + review: BuildModelReview | null; + decide: ((accepted: boolean) => void) | null; + declined: boolean; + stopping: boolean; + cancelPending: boolean; + cancelFailed: boolean; +} + +/** One mounted instance owns consent and its AbortSignal. The parent keys it + * by workspace, registry generation and instance, so navigation disposes the + * old flow before another instance can receive a result or an approval. */ +export default function BuildModelControls({ + instance, + disabled, + service = buildModelService, +}: { + instance: BuildInstanceView; + disabled: boolean; + service?: BuildModelService; +}) { + const { t } = useTranslation(); + const [phase, setPhase] = useState("idle"); + const [review, setReview] = useState(null); + const [error, setError] = useState(""); + const [stderr, setStderr] = useState(""); + const current = useRef(null); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + const attempt = current.current; + current.current = null; + attempt?.controller.abort(); + attempt?.decide?.(false); + }; + }, [service]); + const owns = (attempt: Attempt) => mounted.current && current.current === attempt; + + const start = () => { + if (disabled || current.current) return; + const attempt: Attempt = { + controller: new AbortController(), + review: null, + decide: null, + declined: false, + stopping: false, + cancelPending: false, + cancelFailed: false, + }; + current.current = attempt; + setPhase("preparing"); + setError(""); + setStderr(""); + void Promise.resolve() + .then(() => + service.load( + instance.pluginID, + instance.buildSystemID, + instance.root, + (nativeReview, signal) => + new Promise((resolve) => { + if (!owns(attempt) || signal.aborted || attempt.controller.signal.aborted) { + resolve(false); + return; + } + attempt.review = nativeReview; + let decided = false; + const decide = (accepted: boolean) => { + if (decided) return; + decided = true; + signal.removeEventListener("abort", abort); + attempt.controller.signal.removeEventListener("abort", abort); + attempt.decide = null; + if (owns(attempt)) { + setReview(null); + setPhase(accepted ? "loading" : "stopping"); + } + resolve(accepted); + }; + const abort = () => { + attempt.stopping = true; + decide(false); + }; + attempt.decide = decide; + signal.addEventListener("abort", abort, { once: true }); + attempt.controller.signal.addEventListener("abort", abort, { once: true }); + setReview(nativeReview); + setPhase("review"); + }), + {}, + { + signal: attempt.controller.signal, + onPrepared: (prepared) => { + if (owns(attempt)) attempt.review = prepared; + }, + }, + ), + ) + .then( + (result) => { + if (!owns(attempt) || attempt.cancelPending) return; + if (attempt.cancelFailed) return; + current.current = null; + setReview(null); + if (attempt.stopping || !result) setPhase(attempt.declined ? "declined" : "stopped"); + else { + setPhase("ready"); + setStderr(result.stderr); + } + }, + (failure: unknown) => { + if (!owns(attempt)) return; + setReview(null); + setError(failure instanceof Error ? failure.message : String(failure)); + if (failure instanceof BuildModelCancellationError || attempt.stopping) + setPhase("cancelFailed"); + else { + current.current = null; + setPhase("failed"); + } + }, + ); + }; + const cancel = () => { + const attempt = current.current; + if (!attempt || attempt.cancelPending) return; + attempt.stopping = true; + attempt.cancelFailed = false; + setError(""); + setPhase("stopping"); + setReview(null); + if (!attempt.review) { + attempt.controller.abort(); + return; + } + attempt.cancelPending = true; + // Native cancel waits for cleanup. A failed request keeps Stop retryable; + // the load's late result never claims success for this stopping attempt. + void Promise.resolve() + .then(() => service.cancel(attempt.review!.operationId)) + .then( + () => { + attempt.cancelPending = false; + if (!owns(attempt)) return; + attempt.controller.abort(); + attempt.decide?.(false); + current.current = null; + setError(""); + setPhase("stopped"); + }, + (failure: unknown) => { + attempt.cancelPending = false; + if (!owns(attempt)) return; + attempt.cancelFailed = true; + setError(failure instanceof Error ? failure.message : String(failure)); + setPhase("cancelFailed"); + }, + ); + }; + const decide = (accepted: boolean) => { + const attempt = current.current; + if (!attempt) return; + attempt.declined = !accepted; + attempt.decide?.(accepted); + }; + const busy = ["preparing", "review", "loading", "stopping", "cancelFailed"].includes(phase); + return ( +
    +
    + + {busy && ( + + )} + {["preparing", "loading", "stopping"].includes(phase) && } + {phase !== "idle" && ( + + {t(`buildSystems.model.phase.${phase}`)} + + )} +
    + {error && ( +

    + {t( + phase === "cancelFailed" + ? "buildSystems.model.cancelFailed" + : "buildSystems.model.failed", + { message: error }, + )} +

    + )} + {stderr && ( +
    +          {stderr}
    +        
    + )} + {review && ( + decide(false)} + footer={ + <> + + + + } + > +
    +
    {t("buildSystems.model.plugin")}
    +
    {review.pluginId}
    +
    {t("buildSystems.model.buildSystem")}
    +
    {review.buildSystemId}
    +
    {t("buildSystems.model.workspace")}
    +
    {review.workspaceRoot}
    +
    {t("buildSystems.model.instance")}
    +
    {review.root}
    +
    {t("buildSystems.model.operation")}
    +
    {review.operationId}
    +
    {t("buildSystems.model.generation")}
    +
    {review.generation}
    +
    +

    + {t( + review.executesProjectCode + ? "buildSystems.model.projectCode" + : "buildSystems.model.noProjectCode", + )} +

    +

    + {t( + review.mayUseNetwork ? "buildSystems.model.network" : "buildSystems.model.noNetwork", + )} +

    +
    + )} +
    + ); +} diff --git a/windows/tauri/src/features/build-systems/components/build-system-pane.tsx b/windows/tauri/src/features/build-systems/components/build-system-pane.tsx index ed3882cee7..39d3153527 100644 --- a/windows/tauri/src/features/build-systems/components/build-system-pane.tsx +++ b/windows/tauri/src/features/build-systems/components/build-system-pane.tsx @@ -13,21 +13,30 @@ import { setBuildOption, } from "../build-systems-service"; import type { BuildOptionDescription, BuildWindowState } from "../build-systems-types"; +import BuildModelControls, { type BuildModelService } from "./build-model-controls"; /** - * Native tool window for one declarative build system. This batch renders - * the read-only model (projects, targets, tasks), option controls resolved - * by Core, and staleness/empty states; task, test, and run actions arrive - * with the process-executing milestone and have no entries here yet. + * Tool window for one declarative build system: Core-resolved options, + * model data and an explicit consent flow for command-backed models. + * Task, test and run execution remain with their own feature owners. */ -export default function BuildSystemPane({ view, onClose }: { view: string; onClose: () => void }) { +export default function BuildSystemPane({ + view, + onClose, + modelService, +}: { + view: string; + onClose: () => void; + modelService?: BuildModelService; +}) { const { t } = useTranslation(); const workspaceId = useActiveWorkspaceId(); const parsed = parseBuildSystemViewId(view); const status = useBuildSystemsStore((state) => state.status); const error = useBuildSystemsStore((state) => state.error); const issues = useBuildSystemsStore((state) => state.issues); + const generation = useBuildSystemsStore((state) => state.generation); const windowState = useBuildSystemsStore((state) => (parsed ? state.windows[view] : undefined)); const selectInstance = useBuildSystemsStore((state) => state.actions.selectInstance); @@ -161,6 +170,8 @@ export default function BuildSystemPane({ view, onClose }: { view: string; onClo windowState={windowState} root={instance.root} disabled={status === "loading"} + generation={generation} + modelService={modelService} /> )}
    @@ -173,10 +184,14 @@ function InstanceBody({ windowState, root, disabled, + generation, + modelService, }: { windowState: BuildWindowState; root: string; disabled: boolean; + generation: number | null; + modelService?: BuildModelService; }) { const { t } = useTranslation(); const instance = @@ -184,6 +199,14 @@ function InstanceBody({ return (
    + {instance.modelSource === "command" && ( + + )} {instance.modelError && (

    {t("buildSystems.modelError", { diff --git a/windows/tauri/src/i18n/shared.generated.ts b/windows/tauri/src/i18n/shared.generated.ts index f59ce14e17..302336e718 100644 --- a/windows/tauri/src/i18n/shared.generated.ts +++ b/windows/tauri/src/i18n/shared.generated.ts @@ -769,6 +769,36 @@ export const sharedCatalogs = { "buildSystems.instanceSelector": "Instance", "buildSystems.loading": "Detecting build systems…", "buildSystems.manifestRejected": "{plugin}: {message}", + "buildSystems.model.approve": "Allow and load", + "buildSystems.model.buildSystem": "Build system", + "buildSystems.model.cancel": "Cancel model loading", + "buildSystems.model.cancelFailed": "Cancellation failed: {message}. Retry cancellation before starting another load.", + "buildSystems.model.decline": "Decline", + "buildSystems.model.failed": "Model loading failed: {message}. Check the tool or project configuration, then retry.", + "buildSystems.model.generation": "Generation", + "buildSystems.model.instance": "Instance", + "buildSystems.model.load": "Load model…", + "buildSystems.model.network": "This operation may access the network.", + "buildSystems.model.noNetwork": "The prepared plan does not declare network access.", + "buildSystems.model.noProjectCode": "The prepared plan does not declare project code execution.", + "buildSystems.model.operation": "Operation", + "buildSystems.model.phase.cancelFailed": "Cancellation needs attention", + "buildSystems.model.phase.declined": "Model loading declined", + "buildSystems.model.phase.failed": "Model loading failed", + "buildSystems.model.phase.loading": "Loading model…", + "buildSystems.model.phase.preparing": "Preparing model review…", + "buildSystems.model.phase.ready": "Model loaded", + "buildSystems.model.phase.review": "Waiting for your decision", + "buildSystems.model.phase.stopped": "Model operation ended without applying a result", + "buildSystems.model.phase.stopping": "Cancelling; waiting for operation cleanup…", + "buildSystems.model.plugin": "Plugin", + "buildSystems.model.projectCode": "This operation may execute code from the project.", + "buildSystems.model.reload": "Reload model…", + "buildSystems.model.retryCancel": "Retry cancellation", + "buildSystems.model.reviewTitle": "Review model loading", + "buildSystems.model.stderr": "Model loading stderr", + "buildSystems.model.title": "Build model", + "buildSystems.model.workspace": "Workspace", "buildSystems.modelError": "Model error: {message}", "buildSystems.optionsTitle": "Options", "buildSystems.projectsTitle": "Projects", @@ -5884,6 +5914,36 @@ export const sharedCatalogs = { "buildSystems.instanceSelector": "实例根", "buildSystems.loading": "正在识别构建系统…", "buildSystems.manifestRejected": "{plugin}:{message}", + "buildSystems.model.approve": "同意并加载", + "buildSystems.model.buildSystem": "构建系统", + "buildSystems.model.cancel": "取消加载模型", + "buildSystems.model.cancelFailed": "取消失败:{message}。请先重试取消,再开始新的加载。", + "buildSystems.model.decline": "拒绝", + "buildSystems.model.failed": "加载模型失败:{message}。请检查工具或项目配置后重试。", + "buildSystems.model.generation": "代次", + "buildSystems.model.instance": "实例", + "buildSystems.model.load": "加载模型…", + "buildSystems.model.network": "此操作可能访问网络。", + "buildSystems.model.noNetwork": "准备好的计划未声明访问网络。", + "buildSystems.model.noProjectCode": "准备好的计划未声明执行项目代码。", + "buildSystems.model.operation": "操作", + "buildSystems.model.phase.cancelFailed": "需要处理取消失败", + "buildSystems.model.phase.declined": "已拒绝加载模型", + "buildSystems.model.phase.failed": "加载模型失败", + "buildSystems.model.phase.loading": "正在加载模型…", + "buildSystems.model.phase.preparing": "正在准备模型确认信息…", + "buildSystems.model.phase.ready": "模型已加载", + "buildSystems.model.phase.review": "等待确认", + "buildSystems.model.phase.stopped": "模型操作已结束,未应用结果", + "buildSystems.model.phase.stopping": "正在取消,等待操作清理完成…", + "buildSystems.model.plugin": "插件", + "buildSystems.model.projectCode": "此操作可能执行项目中的代码。", + "buildSystems.model.reload": "重新加载模型…", + "buildSystems.model.retryCancel": "重试取消", + "buildSystems.model.reviewTitle": "确认加载构建模型", + "buildSystems.model.stderr": "模型加载标准错误输出", + "buildSystems.model.title": "构建模型", + "buildSystems.model.workspace": "工作区", "buildSystems.modelError": "模型错误:{message}", "buildSystems.optionsTitle": "选项", "buildSystems.projectsTitle": "工程", From 3a76a0ff157e9c174975e0e7354e2922241c99d2 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Mon, 26 Oct 2026 12:04:53 +0800 Subject: [PATCH 5/7] feat(windows): wire plugin build actions and refresh LSP attachments Connect task and test approval, output and cancellation to the native owner; validate passive Maven model snapshots and queued refreshes. Revoke stale editor attachments and queued text across buffer, workspace and package changes. Cover real WebView lifecycle workflows through the product Quit path. --- .github/workflows/ci-windows.yml | 13 +- .../acceptance_driver.py | 196 +++++- .../acceptance_driver_test.py | 152 ++++ .../fake-lsp-server.mjs | 48 +- .../lsp_lifecycle_acceptance.py | 209 ++++++ .../lsp_lifecycle_acceptance_test.py | 246 +++++++ .../test-windows-declarative-plugin-host.ps1 | 10 + shared/i18n/locales/en-US.json | 34 + shared/i18n/locales/zh-CN.json | 34 + shared/i18n/messages.json | 241 +++++++ .../features/build-system-plugins.json | 14 +- .../features/declarative-plugin-contract.json | 12 +- .../tauri/src-tauri/src/plugin_host/build.rs | 103 ++- .../src/plugin_host/build/host_integration.rs | 28 + .../build/host_integration_tests.rs | 653 ++++++++++++++++++ .../src/plugin_host/build_registry.rs | 175 ++++- .../build-systems/build-run-native.test.ts | 90 +++ .../build-systems/build-run-native.ts | 75 ++ .../build-systems/build-run-session.test.ts | 630 +++++++++++++++++ .../build-systems/build-run-session.ts | 582 ++++++++++++++++ .../features/build-systems/build-run-types.ts | 35 + .../build-systems-service.test.ts | 176 +++++ .../build-systems/build-systems-service.ts | 63 +- .../build-systems/build-systems-types.ts | 16 +- .../components/build-model-controls.test.tsx | 21 +- .../components/build-run-controls.test.tsx | 486 +++++++++++++ .../components/build-run-controls.tsx | 411 +++++++++++ .../components/build-system-pane.test.tsx | 16 +- .../components/build-system-pane.tsx | 34 +- .../output/components/build-output-pane.tsx | 6 +- .../editor/components/code-editor.tsx | 1 + .../use-lsp-attachment-lifecycle.test.tsx | 250 +++++++ .../editor/hooks/use-lsp-integration.ts | 111 ++- .../hooks/use-lsp-queued-owner.test.tsx | 228 ++++++ .../lsp-client-attachment-lifecycle.test.ts | 307 ++++++++ .../src/features/editor/lsp/lsp-client.ts | 90 ++- .../editor/stores/buffer-lsp-close.test.ts | 45 ++ .../features/editor/stores/buffer.store.ts | 8 +- windows/tauri/src/i18n/shared.generated.ts | 68 ++ 39 files changed, 5769 insertions(+), 148 deletions(-) create mode 100644 scripts/plugin-host-acceptance/lsp_lifecycle_acceptance.py create mode 100644 scripts/plugin-host-acceptance/lsp_lifecycle_acceptance_test.py create mode 100644 windows/tauri/src-tauri/src/plugin_host/build/host_integration.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/build/host_integration_tests.rs create mode 100644 windows/tauri/src/features/build-systems/build-run-native.test.ts create mode 100644 windows/tauri/src/features/build-systems/build-run-native.ts create mode 100644 windows/tauri/src/features/build-systems/build-run-session.test.ts create mode 100644 windows/tauri/src/features/build-systems/build-run-session.ts create mode 100644 windows/tauri/src/features/build-systems/build-run-types.ts create mode 100644 windows/tauri/src/features/build-systems/components/build-run-controls.test.tsx create mode 100644 windows/tauri/src/features/build-systems/components/build-run-controls.tsx create mode 100644 windows/tauri/src/features/editor/hooks/use-lsp-attachment-lifecycle.test.tsx create mode 100644 windows/tauri/src/features/editor/hooks/use-lsp-queued-owner.test.tsx create mode 100644 windows/tauri/src/features/editor/lsp/lsp-client-attachment-lifecycle.test.ts create mode 100644 windows/tauri/src/features/editor/stores/buffer-lsp-close.test.ts diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index ccaa725171..580919e0f4 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -325,6 +325,9 @@ jobs: "src/features/plugins/declarative-plugins-section.test.tsx", "src/features/build-systems/build-systems-service.test.ts", "src/features/build-systems/build-systems-store.test.ts", + "src/features/build-systems/build-run-native.test.ts", + "src/features/build-systems/build-run-session.test.ts", + "src/features/build-systems/components/build-run-controls.test.tsx", "src/features/build-systems/components/build-model-controls.test.tsx", "src/features/build-systems/components/build-system-pane.test.tsx", "src/features/build-systems/output/build-output-store.test.ts", @@ -335,7 +338,11 @@ jobs: "src/features/build-systems/output/components/build-output-tests.test.tsx", "src/features/editor/lsp/built-in-language-support.test.ts", "src/features/editor/lsp/declarative-language-support.test.ts", - "src/features/editor/hooks/use-lsp-integration-declarative.test.tsx" + "src/features/editor/hooks/use-lsp-integration-declarative.test.tsx", + "src/features/editor/hooks/use-lsp-attachment-lifecycle.test.tsx", + "src/features/editor/hooks/use-lsp-queued-owner.test.tsx", + "src/features/editor/lsp/lsp-client-attachment-lifecycle.test.ts", + "src/features/editor/stores/buffer-lsp-close.test.ts" ) $index = 0 foreach ($suite in $suites) { @@ -349,7 +356,7 @@ jobs: with: python-version: '3.14' - - name: Test plugin acceptance process ownership and marketplace fixtures + - name: Test plugin acceptance process ownership, marketplace and LSP fixtures shell: pwsh timeout-minutes: 4 run: | @@ -359,6 +366,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python -B scripts/plugin-host-acceptance/marketplace_acceptance_test.py --report .artifacts/test-stability/plugin-marketplace-driver.json --dom-browser msedge if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -B scripts/plugin-host-acceptance/lsp_lifecycle_acceptance_test.py --report .artifacts/test-stability/plugin-lsp-lifecycle-driver.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Test Agent panel and capability gate shell: pwsh diff --git a/scripts/plugin-host-acceptance/acceptance_driver.py b/scripts/plugin-host-acceptance/acceptance_driver.py index c818f4b142..64787f581f 100644 --- a/scripts/plugin-host-acceptance/acceptance_driver.py +++ b/scripts/plugin-host-acceptance/acceptance_driver.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import asyncio import contextlib import ctypes import hashlib @@ -21,6 +22,7 @@ import psutil from playwright.sync_api import Page, Playwright, expect, sync_playwright +from playwright._impl._errors import TargetClosedError PLUGIN_ID = "io.github.lithe-acceptance.host" @@ -190,16 +192,21 @@ def terminate_process_tree(pid: int, timeout_seconds: float = 10, inspect_descen targets = list(dict.fromkeys([owner] + (owner.descendants if inspect_descendants else []))) # Each termination targets the original kernel object, including if it # exited between inspection and this call. Never reopen a historical PID. - cleanup_errors = [] + termination_errors = [] for process in targets: try: process.terminate() except Exception as error: # noqa: BLE001 - another owned handle still needs cleanup. - cleanup_errors.append(str(error)) + termination_errors.append((process, str(error))) deadline = time.monotonic() + timeout_seconds while any(process.running() for process in targets) and time.monotonic() < deadline: time.sleep(0.05) - survivors = [process.pid for process in targets if process.running()] + surviving_handles = [process for process in targets if process.running()] + survivors = [process.pid for process in surviving_handles] + # Windows can deny TerminateProcess while a console/process is already + # exiting. The retained object becoming signaled within this same bounded + # cleanup is proof that it exited; preserve errors only for survivors. + cleanup_errors = [message for process, message in termination_errors if process in surviving_handles] if survivors: cleanup_errors.append(f"owned process handles survived cleanup: {survivors}") if inspection_error is not None: @@ -508,6 +515,69 @@ def invoke(page: Page, command: str, arguments: dict | None = None): ) +def disconnect_cdp_observer(browser) -> None: + # For connect_over_cdp, Playwright 1.60 closes its transport, not the + # external browser. Its public close() has no timeout; use the same pinned + # sync bridge with an explicit bound around that transport-only operation. + browser._sync(asyncio.wait_for(browser._impl_obj.close(reason="native acceptance observer detached"), timeout=5)) + if browser.is_connected(): + raise RuntimeError("the acceptance CDP observer remained connected") + + +def application_window_inventory(owner: OwnedProcess) -> list[dict]: + if not owner.running(): + return [] + windows = [] + + @EnumWindowsProc + def callback(hwnd, _): + if window_pid(hwnd) == owner.pid: + title = ctypes.create_unicode_buffer(1024) + user32.GetWindowTextW(hwnd, title, len(title)) + windows.append({"hwnd": int(hwnd), "class": window_class(hwnd), "title": title.value, + "visible": bool(user32.IsWindowVisible(hwnd))}) + return True + + if not user32.EnumWindows(callback, 0): + raise RuntimeError("could not inventory the owned application's top-level windows") + return windows + + +def request_application_quit(page: Page, evidence: dict | None = None) -> dict: + # Match requestApplicationQuit() exactly: getAllWebviewWindows, then emitTo + # every label, including hidden product windows. Native auxiliary HWNDs do + # not represent application windows and must never receive synthetic close. + # The bundled @tauri-apps/api 2.11.1 webviewWindow.js uses this window-list + # command; webview|get_all_webviews belongs to the separate getAllWebviews. + labels = invoke(page, "plugin:window|get_all_windows") + if not isinstance(labels, list) or not labels or any(not isinstance(label, str) or not label for label in labels): + raise RuntimeError("the product did not return its WebviewWindow labels for quit") + evidence = evidence if evidence is not None else {} + evidence.update({"windowLabels": labels, "observedProductPageClosed": False, "dispatch": "started"}) + with page.expect_event("close", timeout=15_000): + try: + page.evaluate("""async (labels) => { + let deadline; + try { + await Promise.race([ + Promise.all(labels.map(label => window.__TAURI_INTERNALS__.invoke("plugin:event|emit_to", { + target: { kind: "AnyLabel", label }, event: "menu_quit_app", payload: null, + }))), + new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error("Application quit dispatch deadline exceeded")), 10000); + }), + ]); + } finally { clearTimeout(deadline); } + }""", labels) + evidence["dispatch"] = "acknowledged" + except TargetClosedError: + # Closing the actual product page can win the acknowledgement + # race. The enclosing event wait still requires that real close. + evidence["dispatch"] = "target-closed-before-acknowledgement" + evidence["observedProductPageClosed"] = True + return evidence + + class AppSession: def __init__( self, @@ -532,6 +602,7 @@ def __init__( self.initial_tree: list[dict] = [] self.root_owner: OwnedProcess | None = None self.owned_tree: list[OwnedProcess] = [] + self.stop_attempts: list[dict] = [] def start(self) -> Page: port = free_port() @@ -600,36 +671,64 @@ def stop(self, require_graceful: bool = True) -> dict: int(item["ProcessId"]): item for item in self.initial_tree + current_tree }.values()) - forced = False - if self.root_owner is not None and self.root_owner.running() and self.page is not None: - with contextlib.suppress(Exception): - invoke(self.page, "plugin:process|exit", {"code": 0}) + attempt = {"shutdownPath": "application-quit-event", "forced": False, "tree": tree, + "observerDetachedAfterWindowClose": False, "quitRequest": None, "survivorsBeforeCleanup": []} + self.stop_attempts.append(attempt) try: - self.process.wait(timeout=20) - except subprocess.TimeoutExpired: - forced = True - terminate_process_tree(self.process.pid) - if self.browser is not None: - with contextlib.suppress(Exception): - self.browser.close() - for stream in (self.stdout_file, self.stderr_file): - if stream is not None: - stream.close() - deadline = time.monotonic() + 10 - survivors = [item for item in self.owned_tree if item.running()] - while survivors and time.monotonic() < deadline: - time.sleep(0.05) - survivors = [item for item in survivors if item.running()] - if survivors: - for item in survivors: - with contextlib.suppress(Exception): - terminate_process_tree(item.pid) - raise RuntimeError(f"owned application process tree left survivors: {[item.pid for item in survivors]}") - if forced and require_graceful: - raise RuntimeError("the isolated Lithe instance required forced termination") - if require_graceful and self.process.returncode != 0: - raise RuntimeError(f"the isolated Lithe instance exited with code {self.process.returncode}") - return {"forced": forced, "tree": tree} + attempt["nativeWindowInventory"] = application_window_inventory(self.root_owner) if self.root_owner is not None else [] + if self.root_owner is not None and self.root_owner.running(): + if self.page is None or self.page.is_closed(): + raise RuntimeError("no live product page can request application quit") + attempt["quitRequest"] = {} + request_application_quit(self.page, attempt["quitRequest"]) + if self.browser is not None: + disconnect_cdp_observer(self.browser) + self.browser = None + attempt["observerDetachedAfterWindowClose"] = bool(attempt["quitRequest"]) + try: + self.process.wait(timeout=20) + except subprocess.TimeoutExpired: + attempt["forced"] = True + terminate_process_tree(self.process.pid) + attempt["rootReturnCode"] = self.process.poll() + deadline = time.monotonic() + 10 + survivors = [item for item in self.owned_tree if item.running()] + while survivors and time.monotonic() < deadline: + time.sleep(0.05) + survivors = [item for item in survivors if item.running()] + if survivors: + attempt["survivorsBeforeCleanup"] = [ + {"pid": item.pid, "creationTime": item.creation_time, "executable": item.executable} + for item in survivors + ] + attempt["forced"] = True + for item in survivors: + with contextlib.suppress(Exception): + terminate_process_tree(item.pid) + raise RuntimeError(f"owned application process tree left survivors: {[item.pid for item in survivors]}") + if attempt["forced"] and require_graceful: + raise RuntimeError("the isolated Lithe instance required forced termination") + if require_graceful and self.process.returncode != 0: + raise RuntimeError(f"the isolated Lithe instance exited with code {self.process.returncode}") + return {"forced": any(item["forced"] for item in self.stop_attempts), "tree": tree, + "attempts": list(self.stop_attempts)} + except Exception as error: + attempt["error"] = f"{type(error).__name__}: {error}" + if self.root_owner is not None and (self.root_owner.running() + or any(item.running() for item in self.owned_tree)): + attempt["forced"] = True + try: + terminate_process_tree(self.process.pid) + except Exception as cleanup_error: + attempt["cleanupError"] = f"{type(cleanup_error).__name__}: {cleanup_error}" + raise + finally: + attempt["rootReturnCode"] = self.process.poll() + for stream in (self.stdout_file, self.stderr_file): + if stream is not None: + stream.close() + self.log_root.mkdir(parents=True, exist_ok=True) + write_json(self.log_root / f"app-{self.sequence}.stop.json", self.stop_attempts) user32 = ctypes.windll.user32 @@ -638,6 +737,8 @@ def stop(self, require_graceful: bool = True) -> dict: user32.EnumChildWindows.argtypes = [wintypes.HWND, EnumWindowsProc, wintypes.LPARAM] user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] user32.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] +user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] +user32.GetWindowTextW.restype = ctypes.c_int user32.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT] user32.GetWindow.restype = wintypes.HWND user32.IsWindow.argtypes = [wintypes.HWND] @@ -969,6 +1070,8 @@ def main() -> int: parser.add_argument("--package-v2", required=True, type=Path) parser.add_argument("--workspace-one", required=True, type=Path) parser.add_argument("--workspace-two", required=True, type=Path) + parser.add_argument("--lsp-lifecycle", action="store_true", + help="Require fresh owned didOpen evidence for retained and reopened editors.") from marketplace_acceptance import add_arguments, validate_arguments add_arguments(parser) args = parser.parse_args() @@ -1063,6 +1166,10 @@ def main() -> int: assert record["disk"]["archiveSha256"] == hashlib.file_digest(archive, "sha256").hexdigest() state_root = args.app_local_data / "plugin-state" / PLUGIN_ID + lifecycle = None + if args.lsp_lifecycle: + from lsp_lifecycle_acceptance import LspLifecycleAcceptance + lifecycle = LspLifecycleAcceptance(sys.modules[__name__], args, reporter, all_owned_fixture_pids) with reporter.step("installed plugin survives application restart"): cleanup_evidence.append(session.stop(require_graceful=True)) session = None @@ -1116,6 +1223,10 @@ def main() -> int: finish_onboarding(page) open_fixture_file(page) all_owned_fixture_pids.update(wait_for_server_count(state_root, 2)) + if lifecycle is not None: + lifecycle.require_did_open({}, "1.0.0-a", "existing-current-attachment") + lifecycle.repeat_reopen(page, "1.0.0-a") + lifecycle.initialize_close_reopen(page, "1.0.0-a") with reporter.step("two product windows revoke both owned process trees on disable"): # The event wait pumps Playwright's protocol dispatcher; # polling its cached pages list cannot receive a new target. @@ -1135,11 +1246,14 @@ def main() -> int: live = wait_for_server_count(state_root, 4) all_owned_fixture_pids.update(live) open_plugin_settings(page) + retained_offsets = lifecycle.snapshot() if lifecycle is not None else None set_plugin_enabled(page, False) wait_catalog(page, "1.0.0", False) wait_pids_dead(live, "both windows' language-server process trees after disable") set_plugin_enabled(page, True) wait_catalog(page, "1.0.0", True) + if lifecycle is not None: + lifecycle.retained(page, "1.0.0-a", "disable and enable", retained_offsets) else: reporter.document_pending( "application exit revokes the language-server process tree", @@ -1171,6 +1285,7 @@ def main() -> int: ) with reporter.step("same-version replacement, update, and rollback preserve provenance"): + retained_offsets = lifecycle.snapshot() if lifecycle is not None else None review = import_review(page, session.process.pid, args.package_v1_b.resolve()) confirm_review(page, review) wait_catalog(page, "1.0.0", True) @@ -1180,7 +1295,10 @@ def main() -> int: with args.package_v1_b.open("rb") as archive: replacement_digest = hashlib.file_digest(archive, "sha256").hexdigest() assert record["disk"]["archiveSha256"] == replacement_digest + if lifecycle is not None: + lifecycle.retained(page, "1.0.0-b", "same-version replacement", retained_offsets) + retained_offsets = lifecycle.snapshot() if lifecycle is not None else None review = import_review(page, session.process.pid, args.package_v2.resolve()) confirm_review(page, review) wait_catalog(page, "1.1.0", True) @@ -1189,18 +1307,27 @@ def main() -> int: assert record["previousVersion"] == "1.0.0" assert record["previousDisk"]["archiveSha256"] == replacement_digest assert active_fixture_marker(args.app_local_data, "1.1.0") == "1.1.0" + if lifecycle is not None: + lifecycle.retained(page, "1.1.0", "update", retained_offsets) + open_plugin_settings(page) + retained_offsets = lifecycle.snapshot() if lifecycle is not None else None select_plugin_details(page).get_by_role("button", name="Roll back…", exact=True).click() wait_catalog(page, "1.0.0", True) assert active_fixture_marker(args.app_local_data, "1.0.0") == "1.0.0-b" assert installation_record(args.app_local_data)["disk"]["archiveSha256"] == replacement_digest + if lifecycle is not None: + lifecycle.retained(page, "1.0.0-b", "rollback", retained_offsets) with reporter.step("uninstall removes package and state"): live = set() if lsp_started: # Exercise uninstall with a genuinely new document after # rollback, independently of restored-editor reactivation. - open_fixture_file(page, "uninstall.litheaccept") + if lifecycle is not None: + lifecycle.select_existing(page) + else: + open_fixture_file(page, "uninstall.litheaccept") live = wait_for_server_count(state_root, 2) all_owned_fixture_pids.update(live) open_plugin_settings(page) @@ -1365,6 +1492,9 @@ def restart_marketplace(): if args.marketplace: from marketplace_acceptance import record_unreached_cases record_unreached_cases(reporter) + if args.lsp_lifecycle: + from lsp_lifecycle_acceptance import record_unreached_cases + record_unreached_cases(reporter) write_json( args.report, { diff --git a/scripts/plugin-host-acceptance/acceptance_driver_test.py b/scripts/plugin-host-acceptance/acceptance_driver_test.py index 20e6f41a90..04c4a86fc4 100644 --- a/scripts/plugin-host-acceptance/acceptance_driver_test.py +++ b/scripts/plugin-host-acceptance/acceptance_driver_test.py @@ -116,17 +116,41 @@ def grouped_plugin_dom(browser_channel: str) -> None: browser.close() +def cdp_observer_detaches_only(browser_channel: str) -> None: + """Prove the pinned client's bounded close leaves its external browser alive.""" + port = driver.free_port() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(channel=browser_channel, headless=True, timeout=10_000, + args=[f"--remote-debugging-port={port}"]) + try: + observer = playwright.chromium.connect_over_cdp(f"http://127.0.0.1:{port}", timeout=10_000) + driver.disconnect_cdp_observer(observer) + assert not observer.is_connected() + assert browser.is_connected(), "observer detach closed a browser it did not launch" + page = browser.new_page() + page.set_content("still-owned", timeout=2_000) + assert page.title() == "still-owned" + finally: + browser.close() + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--report", type=Path) parser.add_argument("--dom-browser", choices=["msedge"], help="Also run the HTML locator regression using this installed browser; no Lithe product is launched.") parser.add_argument("--dom-fixture-child", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--observer-fixture-child", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args() if args.dom_fixture_child: if args.dom_browser is None: parser.error("--dom-fixture-child requires --dom-browser") grouped_plugin_dom(args.dom_browser) return 0 + if args.observer_fixture_child: + if args.dom_browser is None: + parser.error("--observer-fixture-child requires --dom-browser") + cdp_observer_detaches_only(args.dom_browser) + return 0 if args.report is None: parser.error("--report is required") tests = [] @@ -279,6 +303,35 @@ def terminate(self): assert root.terminated and child.terminated assert unrelated.alive and not unrelated.terminated + def cleanup_distinguishes_exiting_and_surviving_handles(): + class ExitingHandle: + pid = 123 + descendants = [] + + def __init__(self, survives): + self.alive = True + self.survives = survives + + def running(self): + return self.alive + + def terminate(self): + self.alive = self.survives + raise RuntimeError("controlled native termination denial") + + exited = ExitingHandle(False) + with patch.dict(driver.owned_processes, {exited.pid: exited}, clear=True): + driver.terminate_process_tree(exited.pid, timeout_seconds=0, inspect_descendants=False) + survivor = ExitingHandle(True) + with patch.dict(driver.owned_processes, {survivor.pid: survivor}, clear=True): + try: + driver.terminate_process_tree(survivor.pid, timeout_seconds=0, inspect_descendants=False) + except RuntimeError as error: + assert "controlled native termination denial" in str(error) + assert "owned process handles survived cleanup: [123]" in str(error) + else: + raise AssertionError("a surviving retained handle was accepted as clean") + def timeout_query_failure_keeps_error_and_reaps_root(): real_popen = driver.subprocess.Popen spawned = [] @@ -443,6 +496,101 @@ def grouped_plugin_actions(): ], timeout_seconds=30) assert result.returncode == 0, result.stderr or result.stdout + def observer_detach_preserves_external_browser(): + result = run_bounded_process([ + sys.executable, "-B", str(Path(__file__).resolve()), + "--dom-browser", args.dom_browser, "--observer-fixture-child", + ], timeout_seconds=30) + assert result.returncode == 0, result.stderr or result.stdout + + def application_quit_uses_all_product_labels_and_waits_for_real_close(): + page = MagicMock() + labels = ["main", "project-2", "hidden-launcher"] + with patch.object(driver, "invoke", return_value=labels) as invoke: + result = driver.request_application_quit(page) + invoke.assert_called_once_with(page, "plugin:window|get_all_windows") + page.expect_event.assert_called_once_with("close", timeout=15_000) + script, emitted = page.evaluate.call_args.args + assert emitted == labels + assert 'Promise.all(labels.map(label' in script + assert '"plugin:event|emit_to"' in script and 'event: "menu_quit_app"' in script + assert 'kind: "AnyLabel", label' in script + assert result == {"windowLabels": labels, "observedProductPageClosed": True, "dispatch": "acknowledged"} + page.reset_mock() + page.evaluate.side_effect = driver.TargetClosedError("fixture page closed during acknowledgement") + with patch.object(driver, "invoke", return_value=labels): + assert driver.request_application_quit(page)["observedProductPageClosed"] is True + page.expect_event.assert_called_once_with("close", timeout=15_000) + page.evaluate.side_effect = RuntimeError("fixture permission denied") + with patch.object(driver, "invoke", return_value=labels): + try: + driver.request_application_quit(page) + except RuntimeError as error: + assert str(error) == "fixture permission denied" + else: + raise AssertionError("a rejected quit dispatch was accepted") + page.reset_mock() + with patch.object(driver, "invoke", return_value=[]): + try: + driver.request_application_quit(page) + except RuntimeError as error: + assert "WebviewWindow labels" in str(error) + else: + raise AssertionError("an empty product window list was accepted") + page.evaluate.assert_not_called() + + def session_observes_quit_before_detach_and_keeps_failed_cleanup_evidence(): + with tempfile.TemporaryDirectory(prefix="lithe-session-stop-") as directory: + root = Path(directory) + session = driver.AppSession(None, root / "app.exe", root, root / "webview", root / "logs", 1) + owner = MagicMock(pid=101) + owner.running.return_value = True + session.root_owner = owner + session.process = MagicMock(pid=101, returncode=0) + session.process.poll.return_value = 0 + session.browser = MagicMock() + session.page = MagicMock() + session.page.is_closed.return_value = False + events = [] + def waited(**_): + events.append("wait") + owner.running.return_value = False + session.process.wait.side_effect = waited + with patch.object(driver, "process_tree", return_value=[]): + with patch.object(driver, "capture_owned_tree", return_value=[]): + with patch.object(driver, "application_window_inventory", side_effect=lambda _: events.append("inventory") or []): + with patch.object(driver, "disconnect_cdp_observer", side_effect=lambda _: events.append("disconnect")): + def request_quit(_page, evidence): + events.append("quit-closed") + evidence.update({"windowLabels": ["main", "hidden"], "observedProductPageClosed": True}) + with patch.object(driver, "request_application_quit", side_effect=request_quit): + result = session.stop() + assert events == ["inventory", "quit-closed", "disconnect", "wait"], events + assert result["forced"] is False + assert result["attempts"][0]["quitRequest"]["windowLabels"] == ["main", "hidden"] + assert result["attempts"][0]["observerDetachedAfterWindowClose"] is True + + child = MagicMock(pid=102, creation_time=12345, executable="C:/fixture/webview.exe") + child.running.return_value = True + session.owned_tree = [child] + clock = iter(range(0, 1000, 20)) + def terminate(_): + child.running.return_value = False + with patch.object(driver.time, "monotonic", side_effect=lambda: next(clock)): + with patch.object(driver, "terminate_process_tree", side_effect=terminate): + try: + session.stop() + except RuntimeError as error: + assert "left survivors: [102]" in str(error) + else: + raise AssertionError("forced descendant cleanup was accepted as graceful") + final = session.stop(require_graceful=False) + assert final["forced"] is True, "a later cleanup attempt hid the original forced cleanup" + assert final["attempts"][1]["survivorsBeforeCleanup"] == [ + {"pid": 102, "creationTime": 12345, "executable": "C:/fixture/webview.exe"}] + recorded = json.loads((root / "logs/app-1.stop.json").read_text(encoding="utf-8")) + assert recorded[1]["forced"] is True and "left survivors" in recorded[1]["error"] + try: run_case("bounded process captures a successful child", completes, tests) run_case("process output decodes UTF-8 and replaces malformed bytes", output_is_utf8_and_loss_tolerant, tests) @@ -451,14 +599,18 @@ def grouped_plugin_actions(): run_case("topology snapshots close their handles and reject inspection errors", topology_snapshot_closes_handles_and_rejects_errors, tests) run_case("wrapper inventory rejects unrelated fixture path lookalikes", wrapper_inventory_rejects_unrelated_lookalikes, tests) run_case("query failure cleans retained handles without touching unrelated processes", query_failure_still_cleans_retained_handles, tests) + run_case("cleanup accepts a signaled original handle but rejects a surviving handle", cleanup_distinguishes_exiting_and_surviving_handles, tests) run_case("timeout preserves query failure and reaps its retained root", timeout_query_failure_keeps_error_and_reaps_root, tests) run_case("timeout terminates the owned process tree", timeout_cleans_tree, tests) run_case("termination targets a retained handle despite a changed PID", retained_handle_ignores_changed_pid, tests) run_case("creation-time and executable mismatches reject ownership", mismatched_identity_is_not_owned, tests) run_case("fixture arguments reject substring lookalikes", fixture_arguments_are_exact, tests) run_case("filesystem aliases preserve the retained process identity", executable_alias_retains_identity, tests) + run_case("application quit uses all product labels and waits for real close", application_quit_uses_all_product_labels_and_waits_for_real_close, tests) + run_case("session observes quit before detach and retains failed cleanup evidence", session_observes_quit_before_detach_and_keeps_failed_cleanup_evidence, tests) if args.dom_browser is not None: run_case("grouped installed rows select scoped detail actions", grouped_plugin_actions, tests, max_ms=35_000) + run_case("CDP observer detach leaves the external browser alive", observer_detach_preserves_external_browser, tests, max_ms=35_000) else: tests.append({ "name": "grouped installed rows select scoped detail actions", diff --git a/scripts/plugin-host-acceptance/fake-lsp-server.mjs b/scripts/plugin-host-acceptance/fake-lsp-server.mjs index 3c05c515de..7fb31fa5dd 100644 --- a/scripts/plugin-host-acceptance/fake-lsp-server.mjs +++ b/scripts/plugin-host-acceptance/fake-lsp-server.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import path from "node:path"; @@ -73,6 +73,7 @@ if (childMode) { let buffer = Buffer.alloc(0); let shutdownRequested = false; + let releaseInitialize = null; function send(message) { const body = Buffer.from(JSON.stringify(message), "utf8"); @@ -81,9 +82,14 @@ if (childMode) { } function handle(message) { - logEvent("message", { method: message.method ?? null, id: message.id ?? null }); + logEvent("message", { + method: message.method ?? null, + id: message.id ?? null, + uri: message.params?.textDocument?.uri ?? null, + documentVersion: message.params?.textDocument?.version ?? null, + }); if (message.method === "initialize") { - send({ + const respond = () => send({ jsonrpc: "2.0", id: message.id, result: { @@ -94,6 +100,41 @@ if (childMode) { serverInfo: { name: "lithe-plugin-host-acceptance", version: fixtureVersion }, }, }); + // This optional gate is disposable control state in app-data, never in + // the signed/installed package. A real filesystem notification controls + // the race; the deadline only fails a forgotten release. + const gatePath = path.join(stateDirectory, "lsp-initialize-gate.json"); + let gate; + try { gate = JSON.parse(readFileSync(gatePath, "utf8")); } catch { gate = null; } + if (gate?.schemaVersion === 1 && /^[a-f0-9]{32}$/.test(gate.token)) { + const token = gate.token; + const releasePath = path.join(stateDirectory, `lsp-initialize-release-${token}`); + if (!existsSync(releasePath)) { + const timer = setTimeout(() => finish(4, "initialize-gate-deadline"), 15_000); + const watcher = watch(stateDirectory, () => { + if (releaseInitialize && existsSync(releasePath)) { + releaseInitialize?.(); + logEvent("initialize-released", { token }); + respond(); + } + }); + watcher.on("error", () => finish(4, "initialize-gate-watch-error")); + releaseInitialize = () => { + clearTimeout(timer); + watcher.close(); + releaseInitialize = null; + }; + logEvent("initialize-gated", { token }); + // Close the watch-registration race against an early release. + if (existsSync(releasePath)) { + releaseInitialize(); + logEvent("initialize-released", { token }); + respond(); + } + return; + } + } + respond(); return; } if (message.method === "shutdown") { @@ -130,6 +171,7 @@ if (childMode) { function finish(code, reason) { if (finished) return; finished = true; + releaseInitialize?.(); writeEvidence("server-stopped", { code, reason, childPid: child.pid }); logEvent("server-stopped", { code, reason, childPid: child.pid }); process.exit(code); diff --git a/scripts/plugin-host-acceptance/lsp_lifecycle_acceptance.py b/scripts/plugin-host-acceptance/lsp_lifecycle_acceptance.py new file mode 100644 index 0000000000..39498c145c --- /dev/null +++ b/scripts/plugin-host-acceptance/lsp_lifecycle_acceptance.py @@ -0,0 +1,209 @@ +"""Optional real-editor LSP lifecycle checks for the native acceptance driver. + +Events are evidence only after an action's byte offsets, for the exact document +and package marker, and from a currently live fixture whose native identity the +driver retained. PID counts alone never establish editor attachment. +""" + +from __future__ import annotations + +import json +import os +import re +import urllib.parse +import urllib.request +import uuid +from pathlib import Path + +from playwright.sync_api import expect + + +REOPEN_CASE = "LSP existing editor closes and immediately reopens three times" +GATE_CASE = "LSP initialize gate survives closing and reopening the same editor" +RETAINED_CASES = tuple(f"LSP retained editor reattaches after {action}" for action in + ("disable and enable", "same-version replacement", "update", "rollback")) +CASES = (REOPEN_CASE, GATE_CASE, *RETAINED_CASES) + + +def scoped_event_files(state_root: Path): + root = state_root.resolve() + if not root.exists(): + return [] + result = [] + for candidate in root.rglob("events.jsonl"): + if candidate.is_symlink() or not candidate.is_file(): + raise ValueError("a fixture event log must be an ordinary owned file") + actual = candidate.resolve() + if root not in actual.parents: + raise ValueError("a fixture event log escaped its plugin state root") + result.append(actual) + return sorted(result) + + +def event_offsets(state_root: Path) -> dict[str, int]: + root = state_root.resolve() + return {str(path.relative_to(root)): path.stat().st_size for path in scoped_event_files(root)} + + +def events_after(state_root: Path, offsets: dict[str, int]): + root = state_root.resolve() + result = [] + for path in scoped_event_files(root): + offset = offsets.get(str(path.relative_to(root)), 0) + if offset < 0 or path.stat().st_size < offset: + raise ValueError("a fixture event log was truncated after its action snapshot") + with path.open("rb") as stream: + stream.seek(offset) + data = stream.read() + # A concurrent append may expose a partial final line. Only completed + # JSON records count; malformed completed records remain a hard error. + for line in data.split(b"\n")[:-1]: + if line: + event = json.loads(line.decode("utf-8")) + if not isinstance(event, dict): + raise ValueError("a fixture event must be an object") + result.append({**event, "stateDirectory": str(path.parent)}) + return result + + +def canonical_file_uri(uri: str) -> str | None: + value = urllib.parse.urlsplit(uri) + if value.scheme != "file" or value.netloc not in ("", "localhost") or value.query or value.fragment: + return None + return os.path.normcase(str(Path(urllib.request.url2pathname(value.path)).resolve())) + + +def matching_did_open(events, marker: str, uri: str, live: set[int], identity_live): + expected = canonical_file_uri(uri) + if expected is None: + raise ValueError("the acceptance target must be a local file URI") + for event in events: + pid = event.get("pid") + if (event.get("event") == "message" and event.get("method") == "textDocument/didOpen" + and event.get("fixtureVersion") == marker + and isinstance(event.get("uri"), str) + and canonical_file_uri(event["uri"]) == expected + and isinstance(event.get("documentVersion"), int) + and event["documentVersion"] >= 0 + and pid in live and identity_live(pid)): + return event + return None + + +class InitializeGate: + """One bounded server gate in writable per-workspace plugin state.""" + + def __init__(self, state_directory: Path, state_root: Path): + self.directory = state_directory.resolve() + if state_root.resolve() not in self.directory.parents: + raise ValueError("initialize gates must stay below this plugin's state root") + if not self.directory.is_dir() or self.directory.is_symlink(): + raise ValueError("initialize gates require an existing ordinary workspace state directory") + self.token = uuid.uuid4().hex + self.path = self.directory / "lsp-initialize-gate.json" + self.release_path = self.directory / f"lsp-initialize-release-{self.token}" + + def __enter__(self): + with self.path.open("x", encoding="utf-8") as stream: + json.dump({"schemaVersion": 1, "token": self.token}, stream) + return self + + def release(self): + self.release_path.touch(exist_ok=True) + + def __exit__(self, *_): + # Release first on failures so no live fixture can retain a watcher. + self.release() + self.path.unlink(missing_ok=True) + # The release marker stays until plugin-state teardown. A late reader + # that already consumed the config must still see its release token. + + +class LspLifecycleAcceptance: + def __init__(self, driver, args, reporter, owned_pids: set[int]): + self.driver = driver + self.args = args + self.reporter = reporter + self.owned_pids = owned_pids + self.state_root = args.app_local_data / "plugin-state" / driver.PLUGIN_ID + self.target = args.workspace_one.resolve() / "acceptance.litheaccept" + self.uri = self.target.as_uri() + self.evidence = args.artifact_root.resolve() / "lsp-lifecycle" + self.evidence.mkdir(parents=True, exist_ok=True) + self.last_open = None + + def snapshot(self): + return event_offsets(self.state_root) + + def require_did_open(self, offsets, marker: str, name: str): + def ready(): + live = self.driver.live_fixture_pids(self.state_root) + self.owned_pids.update(live) + event = matching_did_open(events_after(self.state_root, offsets), marker, self.uri, live, + lambda pid: self.driver.fixture_processes[pid].running()) + return event + event = self.driver.wait_until(ready, 30, f"a fresh owned didOpen for {name}") + self.last_open = event + self.driver.write_json(self.evidence / (re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + ".json"), + {"offsets": offsets, "expectedURI": self.uri, "expectedMarker": marker, "didOpen": event}) + return event + + def select_existing(self, page): + self.driver.close_settings(page) + tab = page.get_by_role("tab", name=re.compile(r"^acceptance\.litheaccept")) + expect(tab).to_have_count(1, timeout=15_000) + tab.click() + expect(tab).to_have_attribute("aria-selected", "true", timeout=15_000) + + def close_reopen(self, page): + self.select_existing(page) + tab = page.get_by_role("tab", name=re.compile(r"^acceptance\.litheaccept")) + tab.hover() + tab.get_by_role("button").click() + expect(tab).to_have_count(0, timeout=15_000) + self.driver.open_fixture_file(page) + + def repeat_reopen(self, page, marker: str): + with self.reporter.step(REOPEN_CASE): + for iteration in range(3): + offsets = self.snapshot() + self.close_reopen(page) + self.require_did_open(offsets, marker, f"close-reopen-{iteration + 1}") + + def retained(self, page, marker: str, action: str, offsets): + with self.reporter.step(f"LSP retained editor reattaches after {action}"): + self.select_existing(page) + self.require_did_open(offsets, marker, action) + + def initialize_close_reopen(self, page, marker: str): + if self.last_open is None: + raise AssertionError("a verified prior didOpen is required to scope the initialize gate") + with self.reporter.step(GATE_CASE): + directory = Path(self.last_open["stateDirectory"]) + with InitializeGate(directory, self.state_root) as gate: + self.driver.open_plugin_settings(page) + self.driver.set_plugin_enabled(page, False) + self.driver.wait_catalog(page, "1.0.0", False) + offsets = self.snapshot() + self.driver.set_plugin_enabled(page, True) + self.driver.wait_catalog(page, "1.0.0", True) + self.select_existing(page) + gated = self.driver.wait_until( + lambda: next((event for event in events_after(self.state_root, offsets) + if event.get("event") == "initialize-gated" and event.get("token") == gate.token + and event.get("fixtureVersion") == marker), None), + 10, "the fixture to reach its controlled initialize boundary") + # No initialize response is possible until this exact token is + # released. Close/reopen while the old attach is still waiting. + self.close_reopen(page) + gate.release() + event = self.require_did_open(offsets, marker, "initialize-close-reopen") + self.driver.write_json(self.evidence / "initialize-order.json", {"gated": gated, "didOpen": event}) + + +def record_unreached_cases(reporter): + completed = {test["name"] for test in reporter.tests} + for name in CASES: + if name not in completed: + reporter.document_pending(name, "An earlier native acceptance step failed; this lifecycle case was not executed.", + "plugin-host-acceptance / native-lsp-lifecycle") diff --git a/scripts/plugin-host-acceptance/lsp_lifecycle_acceptance_test.py b/scripts/plugin-host-acceptance/lsp_lifecycle_acceptance_test.py new file mode 100644 index 0000000000..00c086ca9c --- /dev/null +++ b/scripts/plugin-host-acceptance/lsp_lifecycle_acceptance_test.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Bounded unit and real fixture-protocol checks, never native Lithe evidence.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +import acceptance_driver as driver +from lsp_lifecycle_acceptance import ( + InitializeGate, canonical_file_uri, event_offsets, events_after, matching_did_open, +) + + +SERVER = Path(__file__).with_name("fake-lsp-server.mjs").resolve() + + +def reject(action, expected=ValueError): + try: + action() + except expected: + return + raise AssertionError(f"expected {expected.__name__}") + + +def append(path, event): + with path.open("ab") as stream: + stream.write((json.dumps(event, ensure_ascii=False) + "\n").encode("utf-8")) + + +def did_open(uri, **extra): + return {"event": "message", "method": "textDocument/didOpen", "pid": 10, + "fixtureVersion": "v1", "uri": uri, "documentVersion": 1, **extra} + + +def offsets_and_partial_records(): + with tempfile.TemporaryDirectory(prefix="lithe-lsp-events-") as temporary: + root = Path(temporary) + workspace = root / "workspace" + workspace.mkdir() + events = workspace / "events.jsonl" + uri = (root / "中文 file.litheaccept").as_uri() + append(events, did_open(uri)) + offsets = event_offsets(root) + assert events_after(root, offsets) == [], "an old didOpen satisfied a new action" + fresh = did_open(uri, documentVersion=2) + with events.open("ab") as stream: + stream.write(json.dumps(fresh).encode("utf-8")) + assert events_after(root, offsets) == [], "a partial record counted as complete" + with events.open("ab") as stream: + stream.write(b"\n") + rows = events_after(root, offsets) + assert len(rows) == 1 and rows[0]["documentVersion"] == 2 + assert rows[0]["stateDirectory"] == str(workspace.resolve()) + assert matching_did_open(rows, "v1", uri, {10}, lambda pid: pid == 10) == rows[0] + events.write_bytes(b"") + reject(lambda: events_after(root, offsets)) + + +def wrong_events_cannot_satisfy_attachment(): + uri = (Path(tempfile.gettempdir()) / "lithe fixture.litheaccept").resolve().as_uri() + valid = did_open(uri) + for change in ({"method": "textDocument/didClose"}, {"event": "server-started"}, + {"fixtureVersion": "old"}, {"uri": uri + "-other"}, + {"uri": "https://example.invalid/file"}, {"documentVersion": -1}, + {"documentVersion": None}): + assert matching_did_open([{**valid, **change}], "v1", uri, {10}, lambda _: True) is None, change + assert matching_did_open([valid], "v1", uri, set(), lambda _: True) is None + assert matching_did_open([valid], "v1", uri, {10}, lambda _: False) is None + assert canonical_file_uri("file://remote.invalid/share/file") is None + assert canonical_file_uri(uri + "?query=unrelated") is None + + +def new_state_logs_are_scoped(): + with tempfile.TemporaryDirectory(prefix="lithe-lsp-scope-") as temporary: + parent = Path(temporary) + root, other = parent / "plugin-state", parent / "other-plugin" + root.mkdir() + other.mkdir() + offsets = event_offsets(root) + workspace = root / "new-workspace" + workspace.mkdir() + uri = (parent / "one.litheaccept").as_uri() + append(other / "events.jsonl", did_open(uri, pid=999)) + append(workspace / "events.jsonl", did_open(uri, pid=10)) + rows = events_after(root, offsets) + assert len(rows) == 1 and rows[0]["pid"] == 10 + # A directory junction must not smuggle another plugin's evidence into + # this root. Creating the junction is privilege-free on Windows. + link = root / "outside" + result = driver.run_bounded_process(["cmd", "/c", "mklink", "/J", str(link), str(other)], 5) + assert result.returncode == 0, result.stderr + try: + reject(lambda: events_after(root, offsets)) + finally: + link.rmdir() + + +def gate_failure_releases_only_owned_state(): + with tempfile.TemporaryDirectory(prefix="lithe-lsp-gate-") as temporary: + parent = Path(temporary) + root, workspace = parent / "state", parent / "state" / "workspace" + workspace.mkdir(parents=True) + unrelated = workspace / "other-state.txt" + unrelated.write_text("preserve", encoding="utf-8") + gate = InitializeGate(workspace, root) + try: + with gate: + assert json.loads(gate.path.read_text(encoding="utf-8"))["token"] == gate.token + raise RuntimeError("controlled failure") + except RuntimeError: + pass + assert not gate.path.exists() and gate.release_path.exists() + assert unrelated.read_text(encoding="utf-8") == "preserve" + reject(lambda: InitializeGate(parent, root)) + reject(lambda: InitializeGate(root, root)) + + +def frame(message): + body = json.dumps(message).encode("utf-8") + return f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + body + + +def response(path): + body = path.read_bytes() + if b"\r\n\r\n" not in body: + return None + header, payload = body.split(b"\r\n\r\n", 1) + size = int(header.split(b":", 1)[1]) + return json.loads(payload[:size]) if len(payload) >= size else None + + +def fixture_protocol(interrupt=False): + with tempfile.TemporaryDirectory(prefix="lithe-lsp-protocol-") as temporary: + root = Path(temporary) + workspace = root / "workspace" + workspace.mkdir() + stdout, stderr = root / "stdout.bin", root / "stderr.log" + handles = [] + process = None + try: + with InitializeGate(workspace, root) as gate, stdout.open("wb") as output, stderr.open("wb") as errors: + process = subprocess.Popen( + [shutil.which("node"), str(SERVER), "--state-dir", str(workspace), "--fixture-version", "gate-fixture"], + stdin=subprocess.PIPE, stdout=output, stderr=errors, creationflags=driver.CREATE_NO_WINDOW) + owner = driver.remember_owned_process(process.pid) + assert owner is not None + handles.append(owner) + driver.wait_until(lambda: list(workspace.glob("child-active-*.json")), 5, "the real fixture child") + handles.extend(driver.capture_owned_tree(owner)) + assert len(handles) >= 2 + process.stdin.write(frame({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})) + process.stdin.flush() + offsets = {} + gated = driver.wait_until(lambda: next((event for event in events_after(root, offsets) + if event.get("event") == "initialize-gated"), None), + 5, "the protocol initialize gate") + assert gated["token"] == gate.token and response(stdout) is None + if interrupt: + process.stdin.close() + process.wait(timeout=3) + assert process.returncode == 0 + stopped = events_after(root, offsets)[-1] + assert stopped["event"] == "server-stopped" and stopped["reason"] == "stdin-end" + else: + gate.release() + initialized = driver.wait_until(lambda: response(stdout), 5, "the gated initialize response") + assert initialized["id"] == 1 + uri = (root / "中文 document.litheaccept").as_uri() + process.stdin.write(frame({"jsonrpc": "2.0", "method": "textDocument/didOpen", "params": { + "textDocument": {"uri": uri, "version": 7, "text": "fixture"}}})) + process.stdin.flush() + event = driver.wait_until(lambda: matching_did_open( + events_after(root, offsets), "gate-fixture", uri, {owner.pid}, lambda _: owner.running()), + 5, "the URI and version in the real fixture event") + assert event["documentVersion"] == 7 + process.stdin.write(frame({"jsonrpc": "2.0", "id": 2, "method": "shutdown"}) + + frame({"jsonrpc": "2.0", "method": "exit"})) + process.stdin.flush() + process.wait(timeout=3) + assert process.returncode == 0 + finally: + if process is not None: + try: + driver.terminate_process_tree(process.pid, timeout_seconds=3) + process.wait(timeout=3) + finally: + if process.stdin is not None and not process.stdin.closed: + process.stdin.close() + for handle in set(handles): + assert not handle.running(), f"fixture process {handle.pid} survived cleanup" + handle.close() + if driver.owned_processes.get(handle.pid) is handle: + del driver.owned_processes[handle.pid] + + +CASES = { + "event offsets reject old and partial records": offsets_and_partial_records, + "didOpen requires the exact marker URI version and current owned PID": wrong_events_cannot_satisfy_attachment, + "new logs remain inside the exact plugin state scope": new_state_logs_are_scoped, + "failed gate actions release their owned state without deleting unrelated files": gate_failure_releases_only_owned_state, + "real fixture initialize waits for release then records didOpen URI and version": fixture_protocol, + "interrupted initialize closes its gate and reclaims the real fixture tree": lambda: fixture_protocol(True), +} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", type=Path) + parser.add_argument("--case", choices=list(CASES)) + args = parser.parse_args() + if args.case: + CASES[args.case]() + return 0 + if not args.report: + parser.error("--report is required") + tests = [] + for name in CASES: + started = time.monotonic() + status, details = "passed", "" + try: + result = driver.run_bounded_process([sys.executable, "-B", str(Path(__file__).resolve()), "--case", name], 15) + if result.returncode: + status, details = "failed", result.stderr or result.stdout + except Exception as error: + status, details = "failed", f"{type(error).__name__}: {error}" + tests.append({"name": name, "suite": "lsp-lifecycle-acceptance / fixture-unit", "status": status, + "durationMs": round((time.monotonic() - started) * 1000, 3), "maxMs": 15000, "details": details}) + driver.write_json(args.report, {"runner": "acceptance", "warnMs": 2000, "maxMs": 15000, "tests": tests}) + print(f"{status.upper()} {name}", flush=True) + root = Path(__file__).resolve().parents[2] + generator = root / ".agents/skills/write-stable-tests/scripts/generate-test-report.mjs" + code = "import {writeTestReportArtifacts} from '" + generator.as_uri() + "';writeTestReportArtifacts(process.argv[1]);" + artifacts = driver.run_bounded_process(["node", "--input-type=module", "-e", code, str(args.report.resolve())], 15) + assert artifacts.returncode == 0, artifacts.stderr + return 1 if any(test["status"] != "passed" for test in tests) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-windows-declarative-plugin-host.ps1 b/scripts/test-windows-declarative-plugin-host.ps1 index 2e1a8677c3..091854b78d 100644 --- a/scripts/test-windows-declarative-plugin-host.ps1 +++ b/scripts/test-windows-declarative-plugin-host.ps1 @@ -6,6 +6,7 @@ param( [switch]$ResumeBuild, [string]$ResourceSource = "", [switch]$Marketplace, + [switch]$LspLifecycle, [string]$MarketplaceFixtureRoot = "", [string]$MarketplaceTlsCert = "", [string]$MarketplaceTlsKey = "", @@ -243,6 +244,8 @@ Invoke-NativeChecked -Executable "python" -Arguments @( (Join-Path $fixtureRoot "acceptance_driver_test.py"), (Join-Path $fixtureRoot "marketplace_acceptance.py"), (Join-Path $fixtureRoot "marketplace_acceptance_test.py"), + (Join-Path $fixtureRoot "lsp_lifecycle_acceptance.py"), + (Join-Path $fixtureRoot "lsp_lifecycle_acceptance_test.py"), (Join-Path $fixtureRoot "marketplace_fixture.py"), (Join-Path $fixtureRoot "generate_packages.py") ) -FailureMessage "A plugin host acceptance Python source file has invalid syntax" @@ -265,6 +268,12 @@ Invoke-NativeChecked -Executable "python" -Arguments @( "--report", (Join-Path $stabilityRoot "plugin-marketplace-acceptance-driver.json") ) -FailureMessage "The marketplace fixture and locator unit checks failed" +Invoke-NativeChecked -Executable "python" -Arguments @( + "-B", + (Join-Path $fixtureRoot "lsp_lifecycle_acceptance_test.py"), + "--report", (Join-Path $stabilityRoot "plugin-lsp-lifecycle-driver.json") +) -FailureMessage "The LSP lifecycle event and initialization gate unit checks failed" + $zsh = Get-Command zsh -ErrorAction SilentlyContinue $immutabilityReport = Join-Path $stabilityRoot "plugin-host-acceptance-source-guard.json" if ($null -eq $zsh) { @@ -466,6 +475,7 @@ try { "--workspace-two", $workspaceTwo ) if ($Marketplace) { $driverArguments += "--marketplace" } + if ($LspLifecycle) { $driverArguments += "--lsp-lifecycle" } if ($providedMarketplaceArguments.Count -eq 3) { $driverArguments += @( "--marketplace-fixture-root", ([IO.Path]::GetFullPath($MarketplaceFixtureRoot)), diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index ab2fb30e37..e67ea745bc 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9311,6 +9311,40 @@ "settings.declarativePlugins.sources.errorGeneric": "The source could not be updated. Try again.", "settings.declarativePlugins.sources.corruptNotice": "The saved source list was unreadable. The original file was preserved next to the store and the list was rebuilt from the presets.", "settings.declarativePlugins.sources.retry": "Retry", + "buildSystems.run.title": "Tasks and tests", + "buildSystems.run.action": "Action", + "buildSystems.run.chooseAction": "Choose an action…", + "buildSystems.run.task": "Task", + "buildSystems.run.tests": "Tests", + "buildSystems.run.scope": "Scope", + "buildSystems.run.scope.workspace": "Workspace", + "buildSystems.run.scope.project": "Project", + "buildSystems.run.scope.target": "Target", + "buildSystems.run.project": "Project", + "buildSystems.run.chooseProject": "Choose a project…", + "buildSystems.run.target": "Target", + "buildSystems.run.chooseTarget": "Choose a target…", + "buildSystems.run.modelTask": "Model task", + "buildSystems.run.chooseTask": "Choose a model task…", + "buildSystems.run.suite": "Test suite filter", + "buildSystems.run.case": "Test case filter", + "buildSystems.run.arguments": "Arguments (one literal argument per line)", + "buildSystems.run.prepare": "Prepare task / test…", + "buildSystems.run.cancel": "Cancel task / test", + "buildSystems.run.review": "Review task / test execution", + "buildSystems.run.approve": "Allow and execute", + "buildSystems.run.programs": "Native-reviewed program declarations are shown below. These are templates; native resolves the executable after approval.", + "buildSystems.run.failed": "Task or test operation failed: {message}", + "buildSystems.run.retryDefinitions": "Reload task declarations", + "buildSystems.run.modelReloadRequired": "The model is missing or stale. Use Load / Reload model above (or Reload for a static model), then retry this action.", + "buildSystems.run.phase.preparing": "Saving and synchronizing before review…", + "buildSystems.run.phase.review": "Waiting for your decision", + "buildSystems.run.phase.starting": "Subscribing before execution…", + "buildSystems.run.phase.running": "Task / test operation active", + "buildSystems.run.phase.stopping": "Cancelling; waiting for native cleanup…", + "buildSystems.run.phase.cancelFailed": "Cancellation needs attention; retry Stop", + "buildSystems.run.phase.failed": "Task / test preparation failed", + "buildSystems.run.phase.ended": "Task / test operation ended", "buildSystems.model.title": "Build model", "buildSystems.model.load": "Load model…", "buildSystems.model.reload": "Reload model…", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 5a0857109f..4165f073b5 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9311,6 +9311,40 @@ "settings.declarativePlugins.sources.errorGeneric": "来源操作失败,请重试。", "settings.declarativePlugins.sources.corruptNotice": "已保存的来源列表无法读取。原文件已保留在存储目录旁,列表已按预置来源重建。", "settings.declarativePlugins.sources.retry": "重试", + "buildSystems.run.title": "任务与测试", + "buildSystems.run.action": "操作", + "buildSystems.run.chooseAction": "选择操作…", + "buildSystems.run.task": "任务", + "buildSystems.run.tests": "测试", + "buildSystems.run.scope": "范围", + "buildSystems.run.scope.workspace": "工作区", + "buildSystems.run.scope.project": "项目", + "buildSystems.run.scope.target": "目标", + "buildSystems.run.project": "项目", + "buildSystems.run.chooseProject": "选择项目…", + "buildSystems.run.target": "目标", + "buildSystems.run.chooseTarget": "选择目标…", + "buildSystems.run.modelTask": "模型任务", + "buildSystems.run.chooseTask": "选择模型任务…", + "buildSystems.run.suite": "测试套件筛选", + "buildSystems.run.case": "测试用例筛选", + "buildSystems.run.arguments": "参数(每行一个原样参数)", + "buildSystems.run.prepare": "准备任务 / 测试…", + "buildSystems.run.cancel": "取消任务 / 测试", + "buildSystems.run.review": "确认执行任务 / 测试", + "buildSystems.run.approve": "同意并执行", + "buildSystems.run.programs": "以下为原生审核的完整程序声明,包含模板;授权后由原生解析实际可执行程序。", + "buildSystems.run.failed": "任务或测试操作失败:{message}", + "buildSystems.run.retryDefinitions": "重新加载任务声明", + "buildSystems.run.modelReloadRequired": "模型缺失或已过期。请先使用上方的加载 / 重新加载模型(静态模型使用重新加载),再重试此操作。", + "buildSystems.run.phase.preparing": "正在保存并同步,随后准备确认信息…", + "buildSystems.run.phase.review": "等待确认", + "buildSystems.run.phase.starting": "正在订阅输出,随后执行…", + "buildSystems.run.phase.running": "任务 / 测试操作进行中", + "buildSystems.run.phase.stopping": "正在取消,等待原生清理完成…", + "buildSystems.run.phase.cancelFailed": "取消失败,请重试停止", + "buildSystems.run.phase.failed": "任务 / 测试准备失败", + "buildSystems.run.phase.ended": "任务 / 测试操作已结束", "buildSystems.model.title": "构建模型", "buildSystems.model.load": "加载模型…", "buildSystems.model.reload": "重新加载模型…", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 143472edb3..6f86fbf846 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -88424,6 +88424,247 @@ "count": "integer" } }, + { + "id": "buildSystems.run.title", + "description": "Windows declared task/test workflow: title", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.action", + "description": "Windows declared task/test workflow: action", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.chooseAction", + "description": "Windows declared task/test workflow: chooseAction", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.task", + "description": "Windows declared task/test workflow: task", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.tests", + "description": "Windows declared task/test workflow: tests", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.scope", + "description": "Windows declared task/test workflow: scope", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.scope.workspace", + "description": "Windows declared task/test workflow: scope.workspace", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.scope.project", + "description": "Windows declared task/test workflow: scope.project", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.scope.target", + "description": "Windows declared task/test workflow: scope.target", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.project", + "description": "Windows declared task/test workflow: project", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.chooseProject", + "description": "Windows declared task/test workflow: chooseProject", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.target", + "description": "Windows declared task/test workflow: target", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.chooseTarget", + "description": "Windows declared task/test workflow: chooseTarget", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.modelTask", + "description": "Windows declared task/test workflow: modelTask", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.chooseTask", + "description": "Windows declared task/test workflow: chooseTask", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.suite", + "description": "Windows declared task/test workflow: suite", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.case", + "description": "Windows declared task/test workflow: case", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.arguments", + "description": "Windows declared task/test workflow: arguments", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.prepare", + "description": "Windows declared task/test workflow: prepare", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.cancel", + "description": "Windows declared task/test workflow: cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.review", + "description": "Windows declared task/test workflow: review", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.approve", + "description": "Windows declared task/test workflow: approve", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.programs", + "description": "Windows declared task/test workflow: programs", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.failed", + "description": "Windows declared task/test workflow: failed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "buildSystems.run.retryDefinitions", + "description": "Windows declared task/test workflow: retryDefinitions", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.modelReloadRequired", + "description": "Windows declared task/test workflow: modelReloadRequired", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.preparing", + "description": "Windows declared task/test workflow: phase.preparing", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.review", + "description": "Windows declared task/test workflow: phase.review", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.starting", + "description": "Waiting for native output subscription before execution", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.running", + "description": "Windows declared task/test workflow: phase.running", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.stopping", + "description": "Windows declared task/test workflow: phase.stopping", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.cancelFailed", + "description": "Windows declared task/test workflow: phase.cancelFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.failed", + "description": "Windows declared task/test workflow: phase.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "buildSystems.run.phase.ended", + "description": "Windows declared task/test workflow: phase.ended", + "platforms": [ + "windows" + ] + }, { "id": "buildSystems.model.title", "description": "Windows command model loading UI: title", diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index 226ac4e4e2..0d160b54a0 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -60,12 +60,20 @@ "windows/tauri/src-tauri/src/plugin_host/build/output.rs", "windows/tauri/src-tauri/src/plugin_host/build/run_tests.rs", "windows/tauri/src/features/build-systems/components/build-model-controls.tsx", - "windows/tauri/src/features/build-systems/components/build-model-controls.test.tsx" + "windows/tauri/src/features/build-systems/components/build-model-controls.test.tsx", + "windows/tauri/src/features/build-systems/build-run-native.ts", + "windows/tauri/src/features/build-systems/build-run-session.ts", + "windows/tauri/src/features/build-systems/components/build-run-controls.tsx", + "windows/tauri/src/features/build-systems/build-run-native.test.ts", + "windows/tauri/src/features/build-systems/build-run-session.test.ts", + "windows/tauri/src/features/build-systems/components/build-run-controls.test.tsx", + "windows/tauri/src-tauri/src/plugin_host/build/host_integration.rs", + "windows/tauri/src-tauri/src/plugin_host/build/host_integration_tests.rs" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "Windows 内部 registry 已接入 Core 静态检测、选项、同步指纹和两阶段模型加载;显式加载/重载 UI 展示原生身份与风险并等待同意,支持拒绝、取消失败重试和竞争请求失效。原生 task/test 两阶段入口已复用 Core taskPlan/testPlan、工具/runtime 解析与 Job Object 执行器,输出按完整身份仅发送所属窗口,Core 解码诊断与测试报告后发布单一终态;清理失败保留占用,过期/未加载命令模型不得用于模型相关执行,报告起始时间在 spawn 前冻结。构建 output 组件仍为注入 port,生产 task/test 前端接线、应用/服务运行、网页桥、语言导入和 Provider 待后续;buildSystems/executableResolution/buildProviders 保持关闭,整体 partial/pending。模型临时文件仍为操作独占的可写 plugin-state,不进入发行资源或复用缓存。", - "verification": "Windows:运行 WindowsRust 逐项计时,覆盖真实 Core 计划/解码、审批前零执行、候选/runtime 环境、失败输出、双窗口、模型/包变更、取消与终态排序、期限和清理失败;报告必须包含快速报告不漏收、未加载/过期模型拒绝执行。前端按文件独立运行 service/store/pane/model-controls 和 output 测试,覆盖同意/拒绝、旧审批、竞争请求、取消失败重试、完整身份、UTF-8/长行预算及源码跳转。Release/WebView2 仍需在能力开放前验证命令模型、任务/测试实际输出、诊断跳转与多窗口清理;单元和组件回归不提升产品 verified。macOS 继续按现有 fixture 和构建宿主流程验证,Provider/网页/导入另行记录。" + "notes": "Windows 内部 registry 复用 Core 静态检测、选项和指纹;hostIntegration 的被动模型继续使用既有 project::maven::scan,与旧 maven.scan 同一事实来源,新增薄适配、精确实例根/选项、完整包快照复验、刷新票据和 NativeLease 生命周期保护,不引入 POM 解析器。显式命令模型加载/重载与 task/test UI 已接真实原生 prepare/approve/execute/cancel 和 window-targeted 输出,执行前保存与同步,按完整模型 ID/tags 选择,复用 output 组件展示诊断/测试和源码跳转,取消失败保留原 owner。文件变更和执行前的原生指纹检查会使旧审批失效,自身保存且指纹未变不会误取消;订阅先于执行,快速终态等待输出排空。原生执行仍由 Core 计划/解码和共享 Job Object owner 负责。能力开关 buildSystems/executableResolution/buildProviders 继续关闭;应用/服务运行、依赖执行、语言导入、网页桥和 Provider 仍待后续,整体 partial/pending。模型临时文件仍为操作独占的可写 plugin-state,不进入发行资源或复用缓存。", + "verification": "Windows:运行宿主 Rust 逐项计时,覆盖真实 Core/旧 Maven 后端模型根、选项、指纹、同版本替包、关闭/撤销和排队刷新失效,及已有 task/test 计划、报告时序、取消和进程树回收。前端每文件独立运行 run-native/session/controls、service/store/pane/model-controls 和 output 测试,覆盖真实文件事件与审批新鲜度、保存→同步→审批→订阅→执行、execute 前输出、快速完成、完整身份、取消失败重试、迟到订阅与诊断跳转。正式 Release/WebView2 构建和 LSP 插件链不替代尚未开放构建能力的真实 task/test 原生产品验收;三类模型、应用/服务、Provider/网页/导入完成前保持 pending。" } diff --git a/shared/platform-feature-matrix/features/declarative-plugin-contract.json b/shared/platform-feature-matrix/features/declarative-plugin-contract.json index b715412806..81c07b6906 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-contract.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-contract.json @@ -58,12 +58,18 @@ "windows/tauri/src/features/editor/lsp/declarative-language-support.test.ts", "windows/tauri/src/features/editor/hooks/use-lsp-integration-declarative.test.tsx", "scripts/test-windows-declarative-plugin-host.ps1", - "scripts/plugin-host-acceptance/acceptance_driver.py" + "scripts/plugin-host-acceptance/acceptance_driver.py", + "windows/tauri/src/features/editor/lsp/lsp-client-attachment-lifecycle.test.ts", + "windows/tauri/src/features/editor/hooks/use-lsp-attachment-lifecycle.test.tsx", + "windows/tauri/src/features/editor/stores/buffer-lsp-close.test.ts", + "windows/tauri/src/features/editor/hooks/use-lsp-queued-owner.test.tsx", + "scripts/plugin-host-acceptance/lsp_lifecycle_acceptance.py", + "scripts/plugin-host-acceptance/lsp_lifecycle_acceptance_test.py" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:[1,2], serverProcess:1, settings:[1,2], ui:[1,2]},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。 后续安全修复已补插件身份传递、运行中和待启动会话排空、跨窗口包变更确认、完整启动前检查及 ui 1 文本/命令入口;目前 WebView 仍为独立窗口,停靠位置、图标、动作状态及布局持久化未完成。 M1 信任链已接入:windows/tauri/src-tauri/src/plugin_host/signature.rs 与 macOS PluginPackageSignature 字节兼容(canonical payload、UTF-8 base64url token、按原始路径排序、每文件 SHA-256、contentDigest),使用成熟的 ed25519-dalek 校验固定官方公钥(lithe-official-plugins-v2);官方 ID 信任策略由宿主固定(php/java-support=sameTeamAsHost、go-support=publisherPackage),覆盖包自声明,sameTeamAsHost 仅信任 bundled 资源;签名文档存在即必须验证。安装记录升为 schema 2:origin、marketplace/disk provenance(全包内容摘要与批准程序)、当前/上一版本证据成一致事务,版本激活与记录提交同事务、提交失败恢复旧字节与旧记录;schema 1 旧记录不凭 enabled 伪造信任,扫描直接隔离并要求重新安装;bundled 身份仅由可信资源发现器产生,installed 记录声明 bundled(含 previousOrigin)一律隔离,sameTeamAsHost 在扫描、启动与回滚均校验真实资源根包含。prepare 冻结整包摘要与审查程序,confirm 与 staging 对同一审核身份校验,prepare 后替换/新增/删除文件拒绝;磁盘渠道接受已验签 publisherPackage 包(保留真实 disk provenance),sameTeam 磁盘包拒绝。安装确认、staging 发布前、启动扫描、运行/测试计划入口与回滚均校验完整文件集合与摘要(community 与 marketplace origin 都复检已批准摘要);ZIP 大小写冲突条目、reparse point、路径逃逸与签名文档损坏均被拒绝。双端共享公开向量 shared/contracts/plugins/v1/fixtures/signature-vectors.json(RFC 8032 测试密钥锚点,非 ASCII 文件名覆盖),Rust 端经 shared_fixture_verifies_end_to_end 断言;Swift 端 PluginSignatureVectorTests 消费同一文件,macOS 实机运行待 CI。生产私钥不进入源码与测试。 安装中断恢复使用完整版本名备份并复核 durable record 批准摘要,校验插件身份和单段版本路径;真实替换产生的备份清理失败、下一次记录提交失败与越界记录均有 Windows 回归。", - "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移未完成,宿主 UI 仅完成文本和命令入口,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 仍缺停靠、图标和动作状态等对齐,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。 Windows 安全回归:plugin_host::launch 的版本探测必须在父进程退出、后代继承管道时仍有界结束并清理进程树;web 回归验证真实文档来源与工作区文件目标。 Windows 语言资源整合:声明式插件的运行分组、管理页与安装审查使用共享生成词条;中英文回归验证安装名称/版本、程序数量和启停标签,并确认用户值中的占位符保持字面文本。 Windows 宿主修复回归:在 Core startServer 尚未返回时禁用插件,晚到会话必须先停止再拒绝;即时设置携带 pluginId,Core 接受并下推对应会话。缺失 requiredPackagePaths/runtimeRequirements 或 validationArguments 非零退出时拒绝启动。Hello Lithe 文本窗口、菜单、命令面板和工具栏可见,禁用后全部撤销;停靠、图标、动作状态及持久布局仍待接入,保持 partial。;Windows 集成检查发现并修复声明式专有后缀无法进入编辑器 LSP 激活门的问题:未被 Java/Worker 认领的本地文件异步查询原生语言归属,实际启动继续复用现有解析与信任检查;停用清除缓存、拒绝迟到答案,并在文档仍打开时释放附件。门控与 hook 回归覆盖启动、禁用及虚拟/无归属路径。验收驱动以保留进程句柄及创建时间/可执行文件身份清理,拒绝按历史 PID 清理无关进程;完整 Release/WebView2 流程仍待本次构建重验,整体保持 pending。" + "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:[1,2], serverProcess:1, settings:[1,2], ui:[1,2]},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。 后续安全修复已补插件身份传递、运行中和待启动会话排空、跨窗口包变更确认、完整启动前检查及 ui 1 文本/命令入口;目前 WebView 仍为独立窗口,停靠位置、图标、动作状态及布局持久化未完成。 M1 信任链已接入:windows/tauri/src-tauri/src/plugin_host/signature.rs 与 macOS PluginPackageSignature 字节兼容(canonical payload、UTF-8 base64url token、按原始路径排序、每文件 SHA-256、contentDigest),使用成熟的 ed25519-dalek 校验固定官方公钥(lithe-official-plugins-v2);官方 ID 信任策略由宿主固定(php/java-support=sameTeamAsHost、go-support=publisherPackage),覆盖包自声明,sameTeamAsHost 仅信任 bundled 资源;签名文档存在即必须验证。安装记录升为 schema 2:origin、marketplace/disk provenance(全包内容摘要与批准程序)、当前/上一版本证据成一致事务,版本激活与记录提交同事务、提交失败恢复旧字节与旧记录;schema 1 旧记录不凭 enabled 伪造信任,扫描直接隔离并要求重新安装;bundled 身份仅由可信资源发现器产生,installed 记录声明 bundled(含 previousOrigin)一律隔离,sameTeamAsHost 在扫描、启动与回滚均校验真实资源根包含。prepare 冻结整包摘要与审查程序,confirm 与 staging 对同一审核身份校验,prepare 后替换/新增/删除文件拒绝;磁盘渠道接受已验签 publisherPackage 包(保留真实 disk provenance),sameTeam 磁盘包拒绝。安装确认、staging 发布前、启动扫描、运行/测试计划入口与回滚均校验完整文件集合与摘要(community 与 marketplace origin 都复检已批准摘要);ZIP 大小写冲突条目、reparse point、路径逃逸与签名文档损坏均被拒绝。双端共享公开向量 shared/contracts/plugins/v1/fixtures/signature-vectors.json(RFC 8032 测试密钥锚点,非 ASCII 文件名覆盖),Rust 端经 shared_fixture_verifies_end_to_end 断言;Swift 端 PluginSignatureVectorTests 消费同一文件,macOS 实机运行待 CI。生产私钥不进入源码与测试。 安装中断恢复使用完整版本名备份并复核 durable record 批准摘要,校验插件身份和单段版本路径;真实替换产生的备份清理失败、下一次记录提交失败与越界记录均有 Windows 回归。 R4 将文档附件绑定 buffer、工作区与插件注册代次;关闭/重开、迟到启动/关闭/排队文本同步不能借用新 owner,插件替换/更新/回滚后已打开编辑器重新附加。扩展可选 LSP 生命周期原生验收,按事件字节偏移、文档 URI、包 marker 和持有句柄身份确认新 didOpen,而非仅数存活 PID。共享契约和宿主能力版本不变,测试与验收范围分别记录,整体继续 partial/pending。", + "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移未完成,宿主 UI 仅完成文本和命令入口,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 仍缺停靠、图标和动作状态等对齐,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。 Windows 安全回归:plugin_host::launch 的版本探测必须在父进程退出、后代继承管道时仍有界结束并清理进程树;web 回归验证真实文档来源与工作区文件目标。 Windows 语言资源整合:声明式插件的运行分组、管理页与安装审查使用共享生成词条;中英文回归验证安装名称/版本、程序数量和启停标签,并确认用户值中的占位符保持字面文本。 Windows 宿主修复回归:在 Core startServer 尚未返回时禁用插件,晚到会话必须先停止再拒绝;即时设置携带 pluginId,Core 接受并下推对应会话。缺失 requiredPackagePaths/runtimeRequirements 或 validationArguments 非零退出时拒绝启动。Hello Lithe 文本窗口、菜单、命令面板和工具栏可见,禁用后全部撤销;停靠、图标、动作状态及持久布局仍待接入,保持 partial。;Windows 集成检查发现并修复声明式专有后缀无法进入编辑器 LSP 激活门的问题:未被 Java/Worker 认领的本地文件异步查询原生语言归属,实际启动继续复用现有解析与信任检查;停用清除缓存、拒绝迟到答案,并在文档仍打开时释放附件。门控与 hook 回归覆盖启动、禁用及虚拟/无归属路径。验收驱动以保留进程句柄及创建时间/可执行文件身份清理,拒绝按历史 PID 清理无关进程;完整 Release/WebView2 流程仍待本次构建重验,整体保持 pending。 Windows R4:逐文件计时验证 pending lookup/attach/detach、相同路径新 buffer、工作区切换、旧同步队列和插件注册变化。Release/WebView2 启用 --lsp-lifecycle,覆盖有界初始化门控期间关闭重开、重复关闭重开及已打开文档在停用再启用、同版本替换、更新和回滚后的新 didOpen;同时验证旧进程树清理和 2382 项发行资源哈希不变。尚未执行的原生步骤不能记为 verified。" } diff --git a/windows/tauri/src-tauri/src/plugin_host/build.rs b/windows/tauri/src-tauri/src/plugin_host/build.rs index 825c2ea041..73ca7630f0 100644 --- a/windows/tauri/src-tauri/src/plugin_host/build.rs +++ b/windows/tauri/src-tauri/src/plugin_host/build.rs @@ -12,7 +12,7 @@ use super::build_registry::{ BuildCoreExecutor, BuildOptionsPersistence, BuildPackageInput, BuildPackageSource, - BuildRegistry, StoredBuildOptions, + BuildRefreshTicket, BuildRegistry, StoredBuildOptions, }; use super::store; use serde::Serialize; @@ -21,6 +21,9 @@ use std::sync::Mutex; use tauri::{AppHandle, Manager, WebviewWindow}; mod host; +pub(super) mod host_integration; +#[cfg(test)] +mod host_integration_tests; mod operation; mod output; mod process; @@ -98,21 +101,43 @@ fn package_input(package: &super::ScannedPackage) -> Option { /// The verified build package source used by the registry. struct HostPackageSource { app: AppHandle, + /// A registry call keeps the package mutation barrier through model read, + /// final identity checks and publication. No process or writable resource + /// is acquired by a passive model. + leases: Mutex>, } impl BuildPackageSource for HostPackageSource { fn accepted_packages(&self) -> Vec { - verified_build_packages(&self.app) - .iter() - .filter_map(package_input) + self.app + .state::() + .packages() + .into_iter() + .filter(|package| package.entry.status == "accepted" && package.entry.enabled) + .filter_map(|package| self.package(&package.entry.plugin_id)) .collect() } fn package(&self, plugin_id: &str) -> Option { - verified_build_packages(&self.app) - .iter() - .find(|package| package.entry.plugin_id == plugin_id) - .and_then(package_input) + { + let mut leases = self.leases.lock().ok()?; + if !leases.contains_key(plugin_id) { + let lease = self + .app + .state::() + .acquire(plugin_id) + .ok()?; + leases.insert(plugin_id.into(), lease); + } + } + let package = super::launch::verified_package_by_id(&self.app, plugin_id).ok()?; + if !package.manifest["buildSystems"] + .as_array() + .is_some_and(|systems| !systems.is_empty()) + { + return None; + } + package_input(&package) } } @@ -226,19 +251,17 @@ pub async fn declarative_plugins_build_refresh( workspace_root: String, ) -> Result { let window_label = window.label().to_string(); - let _suspension = suspend_build_execution(&app, &window_label)?; + let (_suspension, ticket) = suspend_build_refresh(&app, &window_label, &workspace_root)?; stop_build_operations(&app, |operation| { operation.context.window_label == window_label }) .await?; + let checkpoint_app = app.clone(); run_build_task(app, move |registry, source, persistence| { - let view = registry.refresh( - &HostCoreExecutor, - source, - persistence, - &window_label, - &workspace_root, - )?; + let view = + registry.refresh_reserved(&HostCoreExecutor, source, persistence, &ticket, &|| { + live_build_window(&checkpoint_app, &window_label) + })?; serde_json::to_value(view).map_err(|error| error.to_string()) }) .await @@ -259,11 +282,13 @@ pub async fn declarative_plugins_build_set_option( ) -> Result { let window_label = window.label().to_string(); let _suspension = suspend_build_execution(&app, &window_label)?; + let generation = _suspension.generation; stop_build_operations(&app, |operation| { operation.context.window_label == window_label }) .await?; run_build_task(app, move |registry, source, persistence| { + registry.check_generation(&window_label, generation)?; let view = registry.set_option( &HostCoreExecutor, source, @@ -349,10 +374,11 @@ pub async fn declarative_plugins_build_close_workspace( .lock() .map_err(|_| "build registry mutex poisoned")?; if registry.view(&window_label).workspace_root == workspace_root { - registry.suspend_execution(&window_label); + let generation = registry.suspend_execution(&window_label); Some(BuildExecutionSuspension { app: app.clone(), window: window_label.clone(), + generation, }) } else { None @@ -459,6 +485,7 @@ async fn stop_build_operations( struct BuildExecutionSuspension { app: AppHandle, window: String, + generation: u64, } impl Drop for BuildExecutionSuspension { fn drop(&mut self) { @@ -473,7 +500,8 @@ fn suspend_build_execution( app: &AppHandle, window_label: &str, ) -> Result { - app.state::() + let generation = app + .state::() .registry .lock() .map_err(|_| "build registry mutex poisoned")? @@ -481,9 +509,43 @@ fn suspend_build_execution( Ok(BuildExecutionSuspension { app: app.clone(), window: window_label.into(), + generation, }) } +fn live_build_window(app: &AppHandle, window_label: &str) -> Result<(), String> { + if (window_label == "main" || window_label.starts_with("workspace-")) + && app.get_webview_window(window_label).is_some() + { + Ok(()) + } else { + Err("plugin.build.staleContext".into()) + } +} + +fn suspend_build_refresh( + app: &AppHandle, + window_label: &str, + workspace_root: &str, +) -> Result<(BuildExecutionSuspension, BuildRefreshTicket), String> { + live_build_window(app, window_label)?; + let state = app.state::(); + let mut registry = state + .registry + .lock() + .map_err(|_| "build registry mutex poisoned")?; + let generation = registry.suspend_execution(window_label); + let ticket = registry.reserve_refresh(window_label, workspace_root); + Ok(( + BuildExecutionSuspension { + app: app.clone(), + window: window_label.into(), + generation, + }, + ticket, + )) +} + /// Main's native window-destruction hook must call this even when pagehide /// cannot run. Native cancellation also observes a missing host window. pub async fn close_build_window(app: AppHandle, window_label: String) -> Result<(), String> { @@ -515,7 +577,10 @@ where + 'static, { tauri::async_runtime::spawn_blocking(move || { - let source = HostPackageSource { app: app.clone() }; + let source = HostPackageSource { + app: app.clone(), + leases: Mutex::new(Default::default()), + }; let persistence = HostOptionsPersistence { app: app.clone() }; let state = app.state::(); let mut registry = state diff --git a/windows/tauri/src-tauri/src/plugin_host/build/host_integration.rs b/windows/tauri/src-tauri/src/plugin_host/build/host_integration.rs new file mode 100644 index 0000000000..b93c300c77 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/host_integration.rs @@ -0,0 +1,28 @@ +//! Passive model bridge to the existing Core backend. Maven's integration +//! already calls `project::maven::scan`, the same owner as the legacy +//! `maven.scan` entry. This host forwards the exact instance root/options; +//! it does not parse POMs, infer modules, start JDT, or execute a wrapper. + +use super::super::build_registry::BuildCoreExecutor; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +pub(crate) fn read_passive_model( + core: &dyn BuildCoreExecutor, + manifest: &Value, + build_system_id: &str, + workspace_root: &str, + root: &str, + options: &BTreeMap, +) -> Result { + // Core validates the declared source/integration and owns normalization. + // In particular, modelPlan is intentionally unavailable for a passive + // source; invoking it would incorrectly require tools/consent to read it. + core.execute( + "build.decodeModel", + json!({ + "manifest": manifest, "buildSystemID": build_system_id, + "request": { "workspaceRoot": workspace_root, "root": root, "options": options }, + }), + ) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build/host_integration_tests.rs b/windows/tauri/src-tauri/src/plugin_host/build/host_integration_tests.rs new file mode 100644 index 0000000000..d3e91ac6b4 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/build/host_integration_tests.rs @@ -0,0 +1,653 @@ +//! Real Core/legacy Maven backend regressions for passive model admission, +//! exact reactor roots, options, package identity and publication. All races +//! are controlled at a Core return boundary; no tools, JDT or timers run. + +use super::super::build_registry::*; +use super::operation::next_operation_id; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, +}; + +const SYSTEM: &str = "fixture-reactor"; + +struct Workspace(PathBuf); +impl Workspace { + fn new() -> Self { + let workspace = + Self(std::env::temp_dir().join(format!("lithe maven model {}", next_operation_id()))); + workspace.write("north/pom.xml", &pom("north", "pomlibalpha")); + workspace.write("north/lib/pom.xml", &pom("library", "deepsources/mainsources/testassets/main")); + workspace.write("north/lib/deep/pom.xml", &pom("deep", "")); + workspace.write( + "south/pom.xml", + &pom( + "south", + "beta", + ), + ); + for root in ["north", "south"] { + workspace.write(&format!("{root}/mvnw.cmd"), "@echo off\r\nexit /b 99\r\n"); + workspace.write( + &format!("{root}/.mvn/wrapper/maven-wrapper.properties"), + "distributionUrl=https://example.invalid/maven.zip\n", + ); + } + workspace + } + fn path(&self) -> String { + self.0.to_string_lossy().into_owned() + } + fn write(&self, relative: &str, text: &str) { + let path = self.0.join(relative); + std::fs::create_dir_all(path.parent().expect("parent")).expect("directory"); + std::fs::write(path, text).expect("fixture file"); + } +} +impl Drop for Workspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +fn pom(artifact: &str, body: &str) -> String { + format!("4.0.0fixture.test{artifact}1{body}") +} +fn declaration() -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json"); + let mut manifest: Value = + serde_json::from_str(&std::fs::read_to_string(path).expect("fixture")) + .expect("manifest JSON"); + // Integration dispatch belongs to Core's declaration, never a host test + // for one plugin ID or the literal build-system ID `maven`. + manifest["id"] = json!("dev.lithe.fixture.alternate-reactor"); + manifest["buildSystems"][0]["id"] = json!(SYSTEM); + manifest["activationEvents"] = json!([format!("onBuildSystem:{SYSTEM}")]); + manifest +} +struct Packages(Mutex>); +impl Packages { + fn new() -> Self { + let manifest = declaration(); + Self(Mutex::new(Some(BuildPackageInput { + plugin_id: manifest["id"].as_str().expect("ID").into(), + version: "0.1.0".into(), + manifest, + content_identity: "verified-installation:first".into(), + }))) + } + fn input(&self) -> BuildPackageInput { + self.0.lock().expect("package").clone().expect("enabled") + } +} +impl BuildPackageSource for Packages { + fn accepted_packages(&self) -> Vec { + self.0 + .lock() + .expect("package") + .clone() + .into_iter() + .collect() + } + fn package(&self, id: &str) -> Option { + self.0 + .lock() + .expect("package") + .as_ref() + .filter(|package| package.plugin_id == id) + .cloned() + } +} +#[derive(Default)] +struct Options { + values: Mutex, + saves: AtomicUsize, +} +impl BuildOptionsPersistence for Options { + fn load(&self, _: &str) -> Result { + Ok(self.values.lock().expect("options").clone()) + } + fn save(&self, _: &str, options: &StoredBuildOptions) -> Result<(), String> { + *self.values.lock().expect("options") = options.clone(); + self.saves.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} +type CoreBoundary = Arc; +#[derive(Default)] +struct Core { + seen: Mutex>, + after: Option, +} +impl BuildCoreExecutor for Core { + fn execute(&self, command: &str, payload: Value) -> Result { + self.seen + .lock() + .expect("calls") + .push((command.into(), payload.clone())); + let result = super::super::scan::core_execute(command, payload.clone()); + if let Some(after) = &self.after { + after(command, &payload); + } + result + } +} +fn instance(view: &BuildView, root: &str) -> Value { + view.instances + .iter() + .find(|instance| instance["root"] == root) + .expect("instance") + .clone() +} +fn context( + registry: &BuildRegistry, + workspace: &Workspace, + packages: &Packages, +) -> BuildModelContext { + registry + .model_context( + "main", + &workspace.path(), + &packages.input().plugin_id, + SYSTEM, + "north", + ) + .expect("context") +} +fn assert_legacy_projects(model: &Value, root: &str, project: &Value) { + let relative = project["relativePath"].as_str().expect("backend path"); + let path = if relative == "." { + root.into() + } else { + format!("{root}/{relative}") + }; + let normalized = model["projects"] + .as_array() + .expect("projects") + .iter() + .find(|candidate| candidate["path"] == path) + .expect("backend project in build model"); + assert_eq!(normalized["name"], project["artifactId"]); + for property in ["artifactId", "packaging", "groupId", "version"] { + if !project[property].is_null() { + assert_eq!(normalized["properties"][property], project[property]); + } + } + for source in project["sourceRoots"].as_array().expect("source roots") { + let kind = match source["kind"].as_str().expect("kind") { + "mainJava" => "source", + "mainResources" => "resource", + "testJava" => "testSource", + "testResources" => "testResource", + "generatedMain" => "generatedSource", + "generatedTest" => "generatedTestSource", + other => panic!("new backend source kind needs an adapter assertion: {other}"), + }; + let source_path = format!("{path}/{}", source["path"].as_str().expect("source path")); + assert!( + model["contentRoots"] + .as_array() + .expect("content roots") + .iter() + .any(|item| item["projectID"] == normalized["id"] + && item["path"] == source_path + && item["kind"] == kind), + "{source_path}" + ); + } + for child in project["modules"].as_array().expect("modules") { + assert_legacy_projects(model, root, child); + } +} + +#[test] +fn passive_models_reuse_legacy_maven_facts_with_exact_instance_roots() { + let workspace = Workspace::new(); + // Include an ancestor POM, while the declaration explicitly keeps each + // root separate. Each child still must read its own reactor, not this POM. + workspace.write("pom.xml", &pom("ancestor", "pom")); + let packages = Packages::new(); + packages + .0 + .lock() + .expect("package") + .as_mut() + .expect("enabled") + .manifest["buildSystems"][0]["detection"]["nesting"] = json!("separate"); + let core = Core::default(); + let mut registry = BuildRegistry::new(); + let view = registry + .refresh( + &core, + &packages, + &Options::default(), + "main", + &workspace.path(), + ) + .expect("passive refresh"); + for root in ["north", "south"] { + let backend = super::super::scan::core_execute( + "maven.scan", + json!({"root":workspace.0.join(root), "paths":["pom.xml"]}), + ) + .expect("legacy Maven backend"); + let instance = instance(&view, root); + assert_eq!(instance["modelSource"], "hostIntegration"); + assert!(!instance["model"]["projects"] + .as_array() + .expect("projects") + .is_empty()); + assert_legacy_projects(&instance["model"], root, &backend); + let profiles: Vec<_> = backend["profiles"] + .as_array() + .expect("profiles") + .iter() + .map(|profile| profile["id"].clone()) + .collect(); + assert_eq!( + instance["model"]["optionValues"]["profiles"], + json!(profiles) + ); + assert_eq!(instance["stale"], false); + } + let calls = core.seen.lock().expect("calls"); + assert!( + calls.iter().all(|(command, _)| matches!( + command.as_str(), + "build.detect" | "build.decodeModel" | "build.resolveOptions" | "build.syncFingerprint" + )), + "passive discovery never plans a process or probes a wrapper" + ); + assert!(calls + .iter() + .any(|(command, payload)| command == "build.decodeModel" + && payload["request"]["root"] == "north")); +} + +#[test] +fn effective_options_fingerprints_and_model_revisions_stay_together() { + let workspace = Workspace::new(); + let packages = Packages::new(); + let options = Options::default(); + options + .values + .lock() + .expect("options") + .systems + .entry(SYSTEM.into()) + .or_default() + .values = BTreeMap::from([ + ("profiles".into(), json!(["alpha"])), + ("offline".into(), json!(true)), + ]); + let core = Core::default(); + let mut registry = BuildRegistry::new(); + let view = registry + .refresh(&core, &packages, &options, "main", &workspace.path()) + .expect("refresh"); + let old = context(®istry, &workspace, &packages); + assert_eq!(old.options["profiles"], json!(["alpha"])); + let sync = core.execute("build.syncFingerprint", json!({"manifest":old.package.manifest,"buildSystemID":SYSTEM,"request":{"workspaceRoot":workspace.path(),"root":"north","projects":old.model["projects"].as_array().expect("projects").iter().map(|project| project["path"].clone()).collect::>(),"options":old.options}})).expect("Core fingerprint"); + assert_eq!( + instance(&view, "north")["syncDigest"], + sync["sync"]["fingerprint"] + ); + let updated = registry + .set_option( + &core, + &packages, + &options, + "main", + &workspace.path(), + &packages.input().plugin_id, + SYSTEM, + "north", + "profiles", + Some(json!([])), + ) + .expect("explicit empty profile selection"); + let new = context(®istry, &workspace, &packages); + assert!(!registry.context_is_current(&old)); + assert_eq!(new.options["profiles"], json!([])); + assert_ne!( + instance(&view, "north")["optionFingerprint"], + instance(&updated, "north")["optionFingerprint"] + ); + assert_eq!(new.model, instance(&updated, "north")["model"]); + assert!(core + .seen + .lock() + .expect("calls") + .iter() + .any(|(command, payload)| command == "build.decodeModel" + && payload["request"]["root"] == "north" + && payload["request"]["options"]["profiles"] == json!([]))); + registry + .refresh(&core, &packages, &options, "main", &workspace.path()) + .expect("reload"); + assert!( + !registry.context_is_current(&new), + "same-content reload also invalidates approvals" + ); +} + +#[test] +fn changing_a_module_during_passive_decode_marks_its_model_stale() { + let workspace = Arc::new(Workspace::new()); + let changed = Arc::new(AtomicBool::new(false)); + let core = Core { + seen: Mutex::new(vec![]), + after: Some(Arc::new({ + let workspace = workspace.clone(); + let changed = changed.clone(); + move |command, payload| { + if command == "build.decodeModel" + && payload["request"]["root"] == "north" + && !changed.swap(true, Ordering::SeqCst) + { + workspace.write("north/lib/pom.xml", &pom("new-library", "")); + } + } + })), + }; + let packages = Packages::new(); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + let view = registry + .refresh(&core, &packages, &options, "main", &workspace.path()) + .expect("refresh"); + assert_eq!(instance(&view, "north")["stale"], true); + assert_eq!(instance(&view, "south")["stale"], false); + let old = context(®istry, &workspace, &packages); + let view = registry + .refresh( + &Core::default(), + &packages, + &options, + "main", + &workspace.path(), + ) + .expect("stable reload"); + assert_eq!(instance(&view, "north")["stale"], false); + assert!(instance(&view, "north")["model"]["projects"] + .as_array() + .expect("projects") + .iter() + .any(|project| project["name"] == "new-library")); + assert!(!registry.context_is_current(&old)); +} + +#[test] +fn revocation_and_same_version_replacements_during_decode_cannot_publish() { + for replacement in ["disabled", "relocated", "manifest"] { + let workspace = Workspace::new(); + let packages = Arc::new(Packages::new()); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + registry + .refresh( + &Core::default(), + &*packages, + &options, + "main", + &workspace.path(), + ) + .expect("initial"); + let old = context(®istry, &workspace, &packages); + let ticket = registry.reserve_refresh("main", &workspace.path()); + let core = Core { + seen: Mutex::new(vec![]), + after: Some(Arc::new({ + let packages = packages.clone(); + move |command, _| { + if command == "build.decodeModel" { + let mut package = packages.0.lock().expect("package"); + match replacement { + "disabled" => *package = None, + "relocated" => { + package.as_mut().expect("enabled").content_identity = + "verified-installation:second".into() + } + "manifest" => { + package.as_mut().expect("enabled").manifest["displayName"] = + json!("Replaced fixture") + } + _ => unreachable!(), + } + } + } + })), + }; + let result = registry.refresh_reserved(&core, &*packages, &options, &ticket, &|| Ok(())); + assert_eq!(result.err().as_deref(), Some("plugin.build.packageChanged")); + assert!(!registry.context_is_current(&old)); + assert_eq!(options.saves.load(Ordering::SeqCst), 0); + registry.drop_plugin(&old.plugin_id); + assert!(registry.view("main").instances.is_empty()); + assert!(registry + .refresh_reserved(&Core::default(), &*packages, &options, &ticket, &|| Ok(())) + .is_err()); + } +} + +#[test] +fn same_version_sync_invalidates_model_identity_without_adopting_new_package() { + let workspace = Workspace::new(); + let packages = Packages::new(); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + registry + .refresh( + &Core::default(), + &packages, + &options, + "main", + &workspace.path(), + ) + .expect("initial"); + let old = context(®istry, &workspace, &packages); + packages + .0 + .lock() + .expect("package") + .as_mut() + .expect("enabled") + .content_identity = "same-version:new-bytes".into(); + let view = registry + .sync_check( + &Core::default(), + &packages, + "main", + &workspace.path(), + &old.plugin_id, + SYSTEM, + "north", + ) + .expect("sync reports stale using current verified package"); + assert_eq!(instance(&view, "north")["stale"], true); + assert!(!registry.context_is_current(&old)); + assert_eq!( + context(®istry, &workspace, &packages).package, + old.package, + "old model retains its original decoding identity" + ); + assert!(registry + .set_option( + &Core::default(), + &packages, + &options, + "main", + &workspace.path(), + &old.plugin_id, + SYSTEM, + "north", + "offline", + Some(json!(true)) + ) + .is_err()); + assert_eq!(options.saves.load(Ordering::SeqCst), 0); + registry + .refresh( + &Core::default(), + &packages, + &options, + "main", + &workspace.path(), + ) + .expect("explicit reload"); + let new = context(®istry, &workspace, &packages); + assert_eq!(new.package, packages.input()); + assert!(!new.model_stale); +} + +#[test] +fn option_storage_changed_during_model_read_cannot_publish_or_be_overwritten() { + let workspace = Workspace::new(); + let packages = Packages::new(); + let options = Arc::new(Options::default()); + let core = Core { + seen: Mutex::new(vec![]), + after: Some(Arc::new({ + let options = options.clone(); + move |command, _| { + if command == "build.decodeModel" { + options + .values + .lock() + .expect("options") + .systems + .entry(SYSTEM.into()) + .or_default() + .values + .insert("offline".into(), json!(true)); + } + } + })), + }; + let mut registry = BuildRegistry::new(); + let ticket = registry.reserve_refresh("main", &workspace.path()); + assert_eq!( + registry + .refresh_reserved(&core, &packages, &*options, &ticket, &|| Ok(())) + .err() + .as_deref(), + Some("plugin.build.staleContext") + ); + assert!(registry.view("main").instances.is_empty()); + assert_eq!(options.saves.load(Ordering::SeqCst), 0); + assert_eq!( + options.values.lock().expect("options").systems[SYSTEM].values["offline"], + true + ); + // The consumed request cannot be replayed against the changed options. + assert!(registry + .refresh_reserved(&Core::default(), &packages, &*options, &ticket, &|| Ok(())) + .is_err()); +} + +#[test] +fn queued_initial_refresh_cannot_revive_a_closed_or_replaced_workspace() { + for action in [ + "workspace-close", + "window-close", + "new-request", + "option-change", + "plugin-change", + ] { + let workspace = Workspace::new(); + let packages = Packages::new(); + let options = Options::default(); + let mut registry = BuildRegistry::new(); + let ticket = registry.reserve_refresh("main", &workspace.path()); + match action { + "workspace-close" => registry.close_workspace("main", &workspace.path()), + "window-close" => registry.close_window("main"), + "new-request" => { + registry.reserve_refresh("main", "different-workspace"); + } + "option-change" => { + registry.suspend_execution("main"); + registry.resume_execution("main"); + } + "plugin-change" => registry.drop_plugin(&packages.input().plugin_id), + _ => unreachable!(), + } + let core = Core::default(); + assert_eq!( + registry + .refresh_reserved(&core, &packages, &options, &ticket, &|| Ok(())) + .err() + .as_deref(), + Some("plugin.build.staleContext") + ); + assert!( + core.seen.lock().expect("calls").is_empty(), + "refuse before rereading a closed workspace" + ); + assert!(registry.view("main").instances.is_empty()); + let second = registry.reserve_refresh("workspace-second", &workspace.path()); + assert!(!registry + .refresh_reserved(&core, &packages, &options, &second, &|| Ok(())) + .expect("other window remains independent") + .instances + .is_empty()); + } +} + +#[test] +fn window_checkpoint_and_failed_passive_decode_do_not_publish_a_ready_model() { + let workspace = Arc::new(Workspace::new()); + let packages = Packages::new(); + let options = Options::default(); + let live = Arc::new(AtomicBool::new(true)); + let core = Core { + seen: Mutex::new(vec![]), + after: Some(Arc::new({ + let live = live.clone(); + move |command, _| { + if command == "build.decodeModel" { + live.store(false, Ordering::SeqCst); + } + } + })), + }; + let mut registry = BuildRegistry::new(); + let ticket = registry.reserve_refresh("main", &workspace.path()); + assert!(registry + .refresh_reserved( + &core, + &packages, + &options, + &ticket, + &|| if live.load(Ordering::SeqCst) { + Ok(()) + } else { + Err("plugin.build.staleContext".into()) + } + ) + .is_err()); + assert!(registry.view("main").instances.is_empty()); + let core = Core { + seen: Mutex::new(vec![]), + after: Some(Arc::new({ + let workspace = workspace.clone(); + move |command, _| { + if command == "build.detect" { + workspace.write("north/pom.xml", ""); + } + } + })), + }; + let view = registry + .refresh(&core, &packages, &options, "main", &workspace.path()) + .expect("a failed reactor does not hide its sibling"); + assert!(instance(&view, "north")["model"].is_null()); + assert_eq!( + instance(&view, "north")["modelError"]["code"], + "plugin.build.invalidOutput" + ); + assert!(!instance(&view, "south")["model"].is_null()); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/build_registry.rs b/windows/tauri/src-tauri/src/plugin_host/build_registry.rs index b68abd13c7..0bc58d83e5 100644 --- a/windows/tauri/src-tauri/src/plugin_host/build_registry.rs +++ b/windows/tauri/src-tauri/src/plugin_host/build_registry.rs @@ -97,9 +97,21 @@ struct BuildSession { stale: bool, } +impl BuildSession { + fn matches_package(&self, package: &BuildPackageInput) -> bool { + self.plugin_id == package.plugin_id + && self.version == package.version + && self.content_identity == package.content_identity + && self.manifest == package.manifest + } +} + #[derive(Debug, Default)] struct WindowState { execution_suspended: usize, + /// A queued whole-window detection also depends on plugins that have not + /// produced sessions yet, so revocation must invalidate this request too. + refresh_pending: bool, workspace_root: Option, generation: u64, /// Keyed by (plugin ID, build system ID, instance root). @@ -122,6 +134,15 @@ pub struct BuildRegistry { windows: HashMap, } +/// Admission identity for a passive refresh queued behind native cleanup. +/// Reserving it happens before the first await, so a later close or request +/// can invalidate work that has not entered the blocking pool yet. +pub(crate) struct BuildRefreshTicket { + window_label: String, + workspace_root: String, + generation: u64, +} + /// Frozen semantic input for an explicit model load. The host operation /// coordinator owns execution and checks this snapshot again before writes. #[derive(Debug, Clone)] @@ -289,6 +310,7 @@ impl BuildRegistry { window.sessions.clear(); window.issues.clear(); window.workspace_root = None; + window.refresh_pending = false; window.generation = window.generation.saturating_add(1); } } @@ -296,10 +318,12 @@ impl BuildRegistry { /// Invalidate before draining, not after it. A concurrent prepare/confirm /// cannot obtain a fresh executable snapshot while refresh/close is waiting /// for an older native owner to release its process tree. - pub(crate) fn suspend_execution(&mut self, window_label: &str) { + pub(crate) fn suspend_execution(&mut self, window_label: &str) -> u64 { let window = self.windows.entry(window_label.into()).or_default(); window.execution_suspended = window.execution_suspended.saturating_add(1); + window.refresh_pending = false; window.generation = window.generation.saturating_add(1); + window.generation } pub(crate) fn resume_execution(&mut self, window_label: &str) { @@ -308,6 +332,78 @@ impl BuildRegistry { } } + pub(crate) fn check_generation( + &self, + window_label: &str, + generation: u64, + ) -> Result<(), String> { + if self + .windows + .get(window_label) + .is_some_and(|window| window.generation == generation) + { + Ok(()) + } else { + Err("plugin.build.staleContext".into()) + } + } + + pub(crate) fn reserve_refresh( + &mut self, + window_label: &str, + workspace_root: &str, + ) -> BuildRefreshTicket { + let window = self.windows.entry(window_label.into()).or_default(); + window.generation = window.generation.saturating_add(1); + if window.workspace_root.as_deref() != Some(workspace_root) { + window.sessions.clear(); + window.issues.clear(); + } + // Record the requested workspace now. A close arriving before this + // request starts reading must invalidate it even on the first load. + window.workspace_root = Some(workspace_root.into()); + window.refresh_pending = true; + BuildRefreshTicket { + window_label: window_label.into(), + workspace_root: workspace_root.into(), + generation: window.generation, + } + } + + pub(crate) fn refresh_reserved( + &mut self, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + persistence: &dyn BuildOptionsPersistence, + ticket: &BuildRefreshTicket, + checkpoint: &dyn Fn() -> Result<(), String>, + ) -> Result { + self.check_generation(&ticket.window_label, ticket.generation)?; + if !self + .windows + .get(&ticket.window_label) + .is_some_and(|window| { + window.refresh_pending + && window.workspace_root.as_deref() == Some(ticket.workspace_root.as_str()) + }) + { + return Err("plugin.build.staleContext".into()); + } + let result = self.refresh_checked( + core, + packages, + persistence, + &ticket.window_label, + &ticket.workspace_root, + checkpoint, + ); + self.windows + .get_mut(&ticket.window_label) + .expect("reserved window") + .refresh_pending = false; + result + } + /// Re-detects every instance of the workspace for one window. A changed /// workspace root resets the window's sessions and bumps its generation /// so responses already in flight are dropped by the frontend. @@ -319,6 +415,26 @@ impl BuildRegistry { window_label: &str, workspace_root: &str, ) -> Result { + self.refresh_checked( + core, + packages, + persistence, + window_label, + workspace_root, + &|| Ok(()), + ) + } + + fn refresh_checked( + &mut self, + core: &dyn BuildCoreExecutor, + packages: &dyn BuildPackageSource, + persistence: &dyn BuildOptionsPersistence, + window_label: &str, + workspace_root: &str, + checkpoint: &dyn Fn() -> Result<(), String>, + ) -> Result { + checkpoint()?; { let window = self.windows.entry(window_label.to_string()).or_default(); let switched = window.workspace_root.is_some() @@ -332,7 +448,8 @@ impl BuildRegistry { } let manifests = packages.accepted_packages(); - let mut stored = persistence.load(workspace_root)?; + let persisted = persistence.load(workspace_root)?; + let mut stored = persisted.clone(); let mut changed = false; let request_options: BTreeMap> = stored .systems @@ -420,17 +537,13 @@ impl BuildRegistry { instance["modelSource"].as_str(), Some("static" | "hostIntegration") ) { - let response = core.execute( - "build.decodeModel", - json!({ - "manifest": session.manifest, - "buildSystemID": build_system_id, - "request": { - "workspaceRoot": workspace_root, - "root": root, - "options": session.effective_options, - }, - }), + let response = super::build::host_integration::read_passive_model( + core, + &session.manifest, + build_system_id, + workspace_root, + root, + &session.effective_options, )?; let mut loaded = instance.clone(); loaded @@ -485,9 +598,22 @@ impl BuildRegistry { } } + // The source owns trust and lifecycle. Recheck complete identities, + // not just versions: a read must not publish after disable, quarantine, + // relocation, or a same-version replacement during Core decoding. + checkpoint()?; + if persistence.load(workspace_root)? != persisted { + return Err("plugin.build.staleContext".into()); + } + for package in &manifests { + if packages.package(&package.plugin_id).as_ref() != Some(package) { + return Err("plugin.build.packageChanged".into()); + } + } if changed { persistence.save(workspace_root, &stored)?; } + checkpoint()?; let window = self.windows.entry(window_label.to_string()).or_default(); // Even a same-workspace refresh replaces the model/options snapshot. // Preserve the first-generation contract, invalidate subsequent loads. @@ -540,7 +666,7 @@ impl BuildRegistry { root.to_string(), )) .ok_or_else(|| format!("no build instance {build_system_id} at {root}"))?; - if session.version != fresh.version { + if !session.matches_package(&fresh) { return Err(format!( "{plugin_id} changed since the build window loaded it; reload" )); @@ -658,6 +784,9 @@ impl BuildRegistry { // First and only disk write, then re-detect; a failed refresh rolls // the storage back so the model and the file stay consistent. + if packages.package(plugin_id).as_ref() != Some(&fresh) { + return Err("plugin.build.packageChanged".into()); + } persistence.save(workspace_root, &candidate)?; self.refresh(core, packages, persistence, window_label, workspace_root) .map_err(|error| { @@ -714,9 +843,13 @@ impl BuildRegistry { "{plugin_id} changed since the build window loaded it; reload" )); } + let replaced = !session.matches_package(&fresh); // Use the current verified manifest, including same-version reinstalls. let digest = sync_fingerprint(core, workspace_root, &session, &fresh.manifest)?; + if packages.package(plugin_id).as_ref() != Some(&fresh) { + return Err("plugin.build.packageChanged".into()); + } let window = self .windows @@ -730,8 +863,14 @@ impl BuildRegistry { Some(previous) if *previous != digest => session.stale = true, _ => {} } + if replaced { + // A sync may observe the current package to report staleness, but + // cannot adopt its identity for a model decoded by the old one. + // Preserve that snapshot until an explicit passive refresh/load. + session.stale = true; + session.revision = session.revision.saturating_add(1); + } session.sync_digest = Some(digest); - session.manifest = fresh.manifest; Ok(self.view(window_label)) } @@ -745,8 +884,9 @@ impl BuildRegistry { window .sessions .retain(|(owner, _, _), _| owner != plugin_id); - if window.sessions.len() != before { + if window.sessions.len() != before || window.refresh_pending { window.generation += 1; + window.refresh_pending = false; } window .issues @@ -760,7 +900,8 @@ impl BuildRegistry { let Some(window) = self.windows.get_mut(window_label) else { return; }; - if window.workspace_root.as_deref() == Some(workspace_root) && !window.sessions.is_empty() { + if window.workspace_root.as_deref() == Some(workspace_root) { + window.refresh_pending = false; window.sessions.clear(); window.issues.clear(); window.generation += 1; diff --git a/windows/tauri/src/features/build-systems/build-run-native.test.ts b/windows/tauri/src/features/build-systems/build-run-native.test.ts new file mode 100644 index 0000000000..80c737a65e --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-run-native.test.ts @@ -0,0 +1,90 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as native from "@/platform/tauri-core"; +import * as events from "@tauri-apps/api/event"; +import { buildRunNative } from "./build-run-native"; +import type { BuildRunSelection } from "./build-run-types"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + cleanups + .splice(0) + .reverse() + .forEach((cleanup) => cleanup()); +}); + +test("task/test IPC sends only native serde selection fields; the host supplies window and executable identity", async () => { + const calls: Array<{ command: string; args: unknown }> = []; + const invoke = spyOn(native, "invoke").mockImplementation( + async (command: string, args?: unknown): Promise => { + calls.push({ command, args }); + return (command.endsWith("execute_run") ? { kind: "succeeded", exitCode: 0 } : null) as T; + }, + ); + cleanups.push(() => invoke.mockRestore()); + const selection: BuildRunSelection = { + kind: "test", + taskId: null, + projectId: "project-id", + targetId: "target-id", + modelTaskId: null, + suite: "fixture suite", + case: "fixture case", + userArguments: ["literal value with spaces"], + }; + await buildRunNative.prepare( + "D:/fixture/workspace", + "dev.fixture.plugin", + "fixture-system", + "nested", + selection, + ); + await buildRunNative.execute("native-operation"); + await buildRunNative.cancel("native-operation"); + expect(calls).toEqual([ + { + command: "declarative_plugins_build_prepare_run", + args: { + workspaceRoot: "D:/fixture/workspace", + pluginId: "dev.fixture.plugin", + buildSystemId: "fixture-system", + root: "nested", + selection, + }, + }, + { command: "declarative_plugins_build_execute_run", args: { operationId: "native-operation" } }, + { + command: "declarative_plugins_build_cancel_operation", + args: { operationId: "native-operation" }, + }, + ]); +}); +test("output subscription uses the native window target and returns its actual unlistener", async () => { + let listener: ((event: { payload: unknown }) => void) | undefined; + const off = () => {}; + const listen = spyOn(events, "listen").mockImplementation(async (_event, callback) => { + listener = callback as typeof listener; + return off; + }); + cleanups.push(() => listen.mockRestore()); + const received: unknown[] = []; + const release = await buildRunNative.listen("workspace-window-A", (event) => + received.push(event), + ); + expect(listen).toHaveBeenCalledWith("declarative-build-output", expect.any(Function), { + target: { kind: "Window", label: "workspace-window-A" }, + }); + const payload = { + operationId: "native-operation", + pluginId: "dev.fixture.plugin", + buildSystemId: "fixture-system", + workspaceRoot: "D:/fixture/workspace", + root: ".", + generation: 7, + type: "chunk", + stream: "stdout", + text: "first", + }; + listener!({ payload }); + expect(received).toEqual([payload]); + expect(release).toBe(off); +}); diff --git a/windows/tauri/src/features/build-systems/build-run-native.ts b/windows/tauri/src/features/build-systems/build-run-native.ts new file mode 100644 index 0000000000..6235c91ad2 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-run-native.ts @@ -0,0 +1,75 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { listen } from "@tauri-apps/api/event"; +import { invoke } from "@/platform/tauri-core"; +import { syncInstance } from "./build-systems-service"; +import type { BuildRunDefinition, BuildRunReview, BuildRunSelection } from "./build-run-types"; +import type { BuildOutputEvent, BuildRunTerminal } from "./output/build-output-types"; + +export interface BuildRunNative { + windowLabel(): string; + definition(pluginId: string, buildSystemId: string): Promise; + save(workspaceId: string): Promise; + sync(pluginId: string, buildSystemId: string, root: string): Promise; + prepare( + workspaceRoot: string, + pluginId: string, + buildSystemId: string, + root: string, + selection: BuildRunSelection, + ): Promise; + execute(operationId: string): Promise; + cancel(operationId: string): Promise; + listen(windowLabel: string, sink: (event: BuildOutputEvent) => void): Promise<() => void>; + /** Injected only for deterministic event-drain timeout tests. */ + scheduleOutputDrain?: (callback: () => void, milliseconds: number) => () => void; +} + +export const buildRunNative: BuildRunNative = { + windowLabel: () => getCurrentWindow().label, + definition: async (pluginId, buildSystemId) => { + const { getDeclarativePluginManifest, listDeclarativePlugins } = + await import("@/features/plugins/declarative-plugin-launch"); + const { invokePluginDetailsService } = + await import("@/features/plugins/details/plugin-details.service"); + const [manifest, plugins] = await Promise.all([ + getDeclarativePluginManifest(pluginId), + listDeclarativePlugins(), + ]); + const plugin = plugins.find( + (row) => row.pluginId === pluginId && row.enabled && row.status === "accepted", + ); + const system = ( + manifest?.buildSystems as Array | undefined + )?.find((row) => row.id === buildSystemId); + if (!plugin || !system) throw new Error("plugin.build.unavailable"); + const details = await invokePluginDetailsService.load({ pluginId, source: plugin.source }); + const programs = details.programs.filter((program) => { + const value = program as { kind?: string; buildSystemID?: string } | null; + return value?.kind === "buildSystem" && value.buildSystemID === buildSystemId; + }); + if (!details.enabled || details.status !== "accepted" || !programs.length) + throw new Error("plugin.build.unavailable"); + return { tasks: system.tasks ?? [], test: system.test, programs }; + }, + save: async (workspaceId) => + ( + await import("@/features/editor/services/save-workspace-before-launch") + ).saveWorkspaceBeforeLaunch(workspaceId), + sync: syncInstance, + prepare: (workspaceRoot, pluginId, buildSystemId, root, selection) => + invoke("declarative_plugins_build_prepare_run", { + workspaceRoot, + pluginId, + buildSystemId, + root, + selection, + }), + execute: (operationId) => invoke("declarative_plugins_build_execute_run", { operationId }), + cancel: (operationId) => invoke("declarative_plugins_build_cancel_operation", { operationId }), + // Native's payload has no window field; the Tauri target is the window + // identity boundary. Every payload additionally carries all six run keys. + listen: (label, sink) => + listen("declarative-build-output", ({ payload }) => sink(payload), { + target: { kind: "Window", label }, + }), +}; diff --git a/windows/tauri/src/features/build-systems/build-run-session.test.ts b/windows/tauri/src/features/build-systems/build-run-session.test.ts new file mode 100644 index 0000000000..797dfe8418 --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-run-session.test.ts @@ -0,0 +1,630 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { buildSystemViewId, useBuildSystemsStore } from "./build-systems-store"; +import type { BuildView } from "./build-systems-types"; +import type { BuildRunNative } from "./build-run-native"; +import type { BuildRunReview, BuildRunSelection } from "./build-run-types"; +import { BuildRunSession, OUTPUT_DRAIN_TIMEOUT_MS } from "./build-run-session"; +import { useBuildOutputStore } from "./output/build-output-store"; +import type { + BuildOperationIdentity, + BuildOutputEvent, + BuildRunTerminal, +} from "./output/build-output-types"; + +const ROOT = "D:/fixture/run-workspace"; +const PLUGIN = "dev.lithe.fixture.tasks"; +const VIEW = buildSystemViewId(PLUGIN, "fixture"); +const pending: Array<() => void> = []; +const releases: Array<() => void> = []; +const work: Promise[] = []; +function deferred(fallback: T) { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + pending.push(() => resolve(fallback)); + return { promise, resolve, reject }; +} +async function flush() { + for (let index = 0; index < 24; index++) await Promise.resolve(); +} +afterEach(async () => { + releases + .splice(0) + .reverse() + .forEach((release) => release()); + pending.splice(0).forEach((resolve) => resolve()); + await Promise.allSettled(work.splice(0)); + await flush(); + workspaceRuntimeRegistry.resetForTests(); +}); +function view(): BuildView { + return { + generation: 7, + workspaceRoot: ROOT, + issues: [], + instances: [ + { + pluginID: PLUGIN, + buildSystemID: "fixture", + root: ".", + displayName: "Fixture", + markers: [], + modelSource: "command", + model: { projects: [{ id: "project", name: "App", path: "." }] }, + options: [], + stale: false, + syncDigest: "sync", + optionFingerprint: "options", + }, + ], + }; +} +const selection: BuildRunSelection = { + kind: "task", + taskId: "build", + projectId: "project", + targetId: null, + modelTaskId: null, + suite: null, + case: null, + userArguments: ["--flag with spaces"], +}; +function fixture() { + workspaceRuntimeRegistry.activateWorkspace( + { id: "run-tests", name: "Fixture", path: ROOT }, + "ready", + ); + const store = useBuildSystemsStore.getStore("run-tests"); + store.getState().actions.reset(); + const initial = view(); + initial.instances.push({ ...initial.instances[0]!, root: "nested" }); + store.getState().actions.applyView(initial, store.getState().actions.beginRequest()); + const output = useBuildOutputStore.getStore("run-tests"); + output.getState().actions.reset(); + const review: BuildRunReview = { + operationId: "native-run-1", + pluginId: PLUGIN, + buildSystemId: "fixture", + workspaceRoot: ROOT, + root: ".", + generation: 7, + executesProjectCode: true, + mayUseNetwork: true, + kind: "task", + title: "Build fixture", + }; + const terminal = deferred({ kind: "cancelled" }); + const order: string[] = []; + const sinks: Array<(event: BuildOutputEvent) => void> = []; + const unlisten = mock(() => {}); + const native: BuildRunNative = { + windowLabel: () => "workspace-fixture", + definition: mock(async (): Promise => { + order.push("definition"); + return { + tasks: [{ id: "build", displayName: "Build", scopes: ["project"] }], + programs: [ + { + kind: "buildSystem", + buildSystemID: "fixture", + invocation: { executables: [{ kind: "command", command: "fixture-tool" }] }, + }, + ], + }; + }), + save: mock(async () => { + order.push("save"); + }), + sync: mock(async () => { + order.push("sync"); + const actions = store.getState().actions; + actions.applyView(initial, actions.beginRequest()); + }), + prepare: mock(async () => { + order.push("prepare"); + return review; + }), + execute: mock(async () => { + order.push("execute"); + const result = await terminal.promise; + sinks[sinks.length - 1]?.({ ...review, type: "terminal", terminal: result }); + return result; + }), + cancel: mock(async () => { + order.push("cancel"); + }), + listen: mock(async (_window, sink) => { + order.push("listen"); + sinks.push(sink); + return unlisten; + }), + }; + const session = new BuildRunSession("run-tests", PLUGIN, "fixture", ".", store, native); + const release = session.attach(); + releases.push(release); + const start = () => { + const result = session.start(selection); + work.push(result); + return result; + }; + const identity: BuildOperationIdentity = { ...review, title: review.title }; + const send = ( + event: Omit & Partial, + ) => sinks[sinks.length - 1]!({ ...identity, ...event } as BuildOutputEvent); + const state = () => output.getState().sessions[session.sessionId]; + return { + session, + store, + output, + native, + review, + terminal, + order, + sinks, + unlisten, + release, + start, + identity, + send, + state, + }; +} + +test("save, sync and native prepare are ordered; neither opening nor review executes a command", async () => { + const f = fixture(); + expect(f.order).toEqual([]); + const operation = f.start(); + await flush(); + expect(f.order).toEqual(["save", "sync", "definition", "prepare"]); + expect(f.session.getSnapshot().review).toEqual(f.review); + expect(f.native.prepare).toHaveBeenCalledWith(ROOT, PLUGIN, "fixture", ".", selection); + expect(f.native.execute).toHaveBeenCalledTimes(0); + f.session.decide(false); + await operation; + expect(f.native.cancel).toHaveBeenCalledWith(f.review.operationId); + expect(f.native.execute).toHaveBeenCalledTimes(0); +}); +test("the existing build busy barrier spans consent and execution so queued file sync cannot invalidate its own launch", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + expect(f.store.getState().status).toBe("loading"); + f.session.decide(true); + await flush(); + expect(f.store.getState().status).toBe("loading"); + f.terminal.resolve({ kind: "succeeded", exitCode: 0 }); + await operation; + expect(f.store.getState().status).toBe("ready"); +}); +test("listener acquisition completes before execute, including synchronous output and a fast terminal", async () => { + const f = fixture(); + const acquisition = deferred<() => void>(f.unlisten); + f.native.listen = mock(async (_label, sink) => { + f.sinks.push(sink); + f.order.push("listen"); + return acquisition.promise; + }); + f.native.execute = mock(async (): Promise => { + f.send({ type: "chunk", stream: "stdout", text: "first output\n" }); + f.send({ type: "chunk", stream: "stderr", text: "warning\n" }); + f.send({ type: "terminal", terminal: { kind: "succeeded", exitCode: 0 } }); + return { kind: "succeeded", exitCode: 0 }; + }); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + expect(f.native.execute).toHaveBeenCalledTimes(0); + acquisition.resolve(f.unlisten); + await operation; + expect(f.native.listen).toHaveBeenCalledWith("workspace-fixture", expect.any(Function)); + expect(f.state()!.lines.map((row) => [row.stream, row.text])).toEqual([ + ["stdout", "first output"], + ["stderr", "warning"], + ]); + expect(f.state()!.status).toBe("succeeded"); + expect(f.unlisten).toHaveBeenCalledTimes(1); +}); +test("cancel failure keeps the same native owner; retry waits for the native terminal result", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + f.native.cancel = mock(async () => { + throw new Error("fixture cleanup failure"); + }); + await expect(f.session.cancel()).rejects.toThrow("fixture cleanup failure"); + expect(f.state()!.status).toBe("running"); + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + expect(f.store.getState().status).toBe("loading"); + await f.session.start(selection); + expect(f.native.execute).toHaveBeenCalledTimes(1); + const confirmation = deferred(undefined); + f.native.cancel = mock(() => confirmation.promise); + const retry = f.session.cancel(); + await flush(); + expect(f.session.getSnapshot().phase).toBe("stopping"); + confirmation.resolve(); + await retry; + expect(f.state()!.status).toBe("running"); + f.terminal.resolve({ kind: "cancelled", exitCode: null }); + await operation; + expect(f.state()!.status).toBe("cancelled"); + expect(f.store.getState().status).toBe("ready"); + expect(f.native.cancel).toHaveBeenCalledWith(f.review.operationId); +}); +test("a fast invoke result does not truncate queued chunks or decoded diagnostics before the terminal event", async () => { + const f = fixture(); + let timeout: (() => void) | undefined; + const cancelTimer = mock(() => { + timeout = undefined; + }); + f.native.scheduleOutputDrain = (callback, milliseconds) => { + expect(milliseconds).toBe(OUTPUT_DRAIN_TIMEOUT_MS); + timeout = callback; + return cancelTimer; + }; + f.native.execute = mock( + async (): Promise => ({ kind: "succeeded", exitCode: 0 }), + ); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + expect(f.state()!.status).toBe("running"); + expect(timeout).toBeDefined(); + f.send({ type: "chunk", stream: "stdout", text: "queued after invoke\n" }); + f.send({ + type: "diagnostics", + diagnostics: [{ path: "app.ts", line: 2, severity: "warning", message: "queued diagnostic" }], + }); + f.send({ type: "terminal", terminal: { kind: "succeeded", exitCode: 0 } }); + await operation; + expect(f.state()!.lines[0]!.text).toBe("queued after invoke"); + expect(f.state()!.diagnostics[0]!.message).toBe("queued diagnostic"); + expect(cancelTimer).toHaveBeenCalledTimes(1); + expect(timeout).toBeUndefined(); +}); +test("missing terminal delivery uses the real invoke result after a bounded drain and reports missing output", async () => { + const f = fixture(); + let expire: (() => void) | undefined; + const cancelTimer = mock(() => { + expire = undefined; + }); + f.native.scheduleOutputDrain = (callback) => { + expire = callback; + return cancelTimer; + }; + f.native.execute = mock(async (): Promise => ({ kind: "failed", exitCode: 2 })); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + expect(f.state()!.status).toBe("running"); + expire!(); + await operation; + expect(f.state()!.status).toBe("failed"); + expect(f.state()!.terminal!.exitCode).toBe(2); + expect(f.state()!.subscriptionError).toContain("outputUnavailable"); + expect(cancelTimer).toHaveBeenCalledTimes(1); +}); +test("actual native completion releases a failed Stop owner and a late cancellation error cannot affect the next review", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + const cancellation = deferred(undefined); + f.native.cancel = mock(() => cancellation.promise); + const stop = f.session.cancel().catch((error) => error); + await flush(); + f.terminal.resolve({ kind: "succeeded", exitCode: 0 }); + await operation; + expect(f.state()!.status).toBe("succeeded"); + f.review.operationId = "native-run-2"; + const next = f.start(); + await flush(); + expect(f.session.getSnapshot().phase).toBe("review"); + cancellation.reject(new Error("old cancellation error")); + await stop; + expect(f.session.getSnapshot().phase).toBe("review"); + expect(f.session.getSnapshot().error).toBeNull(); + f.native.cancel = mock(async () => {}); + f.session.decide(false); + await next; +}); +test("late cancellation success cannot close a replacement operation's event subscription", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + const cancellation = deferred(undefined); + f.native.cancel = mock(() => cancellation.promise); + const stop = f.session.cancel(); + await flush(); + f.terminal.resolve({ kind: "succeeded", exitCode: 0 }); + await operation; + f.review.operationId = "native-run-2"; + const nextTerminal = deferred({ kind: "cancelled" }); + f.native.execute = mock(async () => { + const terminal = await nextTerminal.promise; + f.sinks[f.sinks.length - 1]!({ ...f.review, type: "terminal", terminal }); + return terminal; + }); + const next = f.start(); + await flush(); + f.session.decide(true); + await flush(); + const releasedBefore = f.unlisten.mock.calls.length; + cancellation.resolve(); + await stop; + expect(f.unlisten.mock.calls.length).toBe(releasedBefore); + expect(f.session.getSnapshot().phase).toBe("running"); + f.sinks[f.sinks.length - 1]!({ + ...f.review, + type: "chunk", + stream: "stdout", + text: "replacement output\n", + }); + expect(f.state()!.lines[0]!.text).toBe("replacement output"); + nextTerminal.resolve({ kind: "cancelled" }); + await next; +}); +test("execute rejection cannot invent a terminal or discard the retryable owner", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + f.terminal.reject(new Error("plugin.build.cleanupFailed")); + await operation; + expect(f.state()!.status).toBe("running"); + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + expect(f.session.getSnapshot().error).toContain("cleanupFailed"); + await f.session.start(selection); + expect(f.native.prepare).toHaveBeenCalledTimes(1); +}); +test("a late authoritative terminal releases the retained owner after an execute rejection", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + f.terminal.reject(new Error("plugin.build.cleanupFailed")); + await operation; + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + f.send({ type: "terminal", terminal: { kind: "cancelled" } }); + expect(f.state()!.status).toBe("cancelled"); + expect(f.session.getSnapshot().phase).toBe("ended"); + expect(f.unlisten).toHaveBeenCalledTimes(1); +}); +test("native cleanup confirmation can release an execute rejection without inventing an early terminal", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + f.terminal.reject(new Error("plugin.build.cleanupFailed")); + await operation; + const gate = deferred(undefined); + f.native.cancel = mock(() => gate.promise); + const stop = f.session.cancel(); + await flush(); + expect(f.state()!.status).toBe("running"); + gate.resolve(); + await stop; + expect(f.state()!.status).toBe("cancelled"); + expect(f.session.getSnapshot().phase).toBe("ended"); +}); +test("a review cancellation failure is retained without an automatic second cancellation", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.native.cancel = mock(async () => { + throw new Error("fixture review cleanup failed"); + }); + await expect(f.session.cancel()).rejects.toThrow("fixture review cleanup failed"); + await operation; + expect(f.native.cancel).toHaveBeenCalledTimes(1); + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + expect(f.native.execute).toHaveBeenCalledTimes(0); + f.native.cancel = mock(async () => {}); + await f.session.cancel(); + expect(f.session.getSnapshot().phase).toBe("ended"); +}); +test("panel reopening preserves a failed Stop handle and receives the same operation's remaining events", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + const staleSink = f.sinks[0]!; + f.native.cancel = mock(async () => { + throw new Error("fixture stop failed while closed"); + }); + f.release(); + await flush(); + expect(f.unlisten).toHaveBeenCalledTimes(1); + expect(f.session.getSnapshot().phase).toBe("cancelFailed"); + releases.push(f.session.attach()); + await flush(); + staleSink({ ...f.identity, type: "chunk", stream: "stdout", text: "closed callback" }); + f.send({ type: "chunk", stream: "stdout", text: "reopened\n" }); + expect(f.state()!.lines.map((row) => row.text)).toEqual(["reopened"]); + f.native.cancel = mock(async () => {}); + await f.session.cancel(); + f.terminal.resolve({ kind: "cancelled" }); + await operation; + expect(f.state()!.status).toBe("cancelled"); +}); +for (const key of [ + "operationId", + "pluginId", + "buildSystemId", + "workspaceRoot", + "root", + "generation", +] as const) { + test(`foreign ${key} events cannot enter the output store`, async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + const wrong = { + ...f.identity, + [key]: key === "generation" ? 8 : "foreign", + type: "chunk", + stream: "stdout", + text: "wrong", + }; + f.sinks[0]!(wrong as BuildOutputEvent); + expect(f.state()!.lines).toEqual([]); + f.send({ + type: "diagnostics", + diagnostics: [ + { + path: "src/main.ts", + line: 3, + column: 2, + severity: "error", + message: "fixture diagnostic", + }, + ], + }); + f.send({ + type: "testResults", + results: { + tests: [ + { + suite: "suite", + case: "failed", + status: "failed", + invocations: 1, + path: "src/main.ts", + line: 3, + }, + ], + summary: { total: 1, passed: 0, failed: 1, errors: 0, skipped: 0 }, + success: false, + }, + }); + f.terminal.resolve({ kind: "failed", exitCode: 1 }); + await operation; + expect(f.state()!.diagnostics[0]!.message).toBe("fixture diagnostic"); + expect(f.state()!.testResults!.summary.failed).toBe(1); + }); +} +test("malformed native payloads cannot crash ingestion or manufacture success", async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + for (const payload of [ + { type: "diagnostics", diagnostics: [{ path: null, message: "bad", line: 1 }] }, + { type: "testResults", results: { tests: [{ suite: null }], summary: {}, success: false } }, + { type: "terminal", terminal: { kind: "succeeded", message: {} } }, + { type: "chunk", stream: "stdout", text: 12 }, + ]) + f.sinks[0]!({ ...f.identity, ...payload } as unknown as BuildOutputEvent); + expect(f.state()!.status).toBe("running"); + expect(f.state()!.diagnostics).toEqual([]); + expect(f.state()!.testResults).toBeNull(); + f.terminal.resolve({ kind: "succeeded", exitCode: 0 }); + await operation; +}); +for (const change of [ + "workspace", + "generation", + "selection", + "disable", + "reload", + "window", + "unmount", +] as const) { + test(`${change} invalidates open consent before a stale approval can execute`, async () => { + const f = fixture(); + const operation = f.start(); + await flush(); + if (change === "workspace") + workspaceRuntimeRegistry.activateWorkspace( + { id: "other", name: "Other", path: "D:/fixture/other" }, + "ready", + ); + if (change === "generation") f.store.setState({ generation: 8 }); + if (change === "selection") f.store.getState().actions.selectInstance(VIEW, "nested"); + if (change === "disable") f.store.getState().actions.dropPlugin(PLUGIN); + if (change === "reload") f.store.getState().actions.beginRequest(); + if (change === "window") { + f.native.windowLabel = () => "workspace-other"; + f.store.setState({}); + } + if (change === "unmount") f.release(); + f.session.decide(true); + await operation; + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(f.native.cancel).toHaveBeenCalledWith(f.review.operationId); + }); +} +test("unmount before a late prepare cancels the newly returned native slot without opening approval", async () => { + const f = fixture(); + const gate = deferred(f.review); + f.native.prepare = mock(() => gate.promise); + const operation = f.start(); + await flush(); + f.release(); + gate.resolve(f.review); + await operation; + expect(f.session.getSnapshot().review).toBeNull(); + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(f.native.cancel).toHaveBeenCalledTimes(1); +}); +test("unmount while subscribing releases a late listener and never invokes execute", async () => { + const f = fixture(); + const gate = deferred<() => void>(f.unlisten); + f.native.listen = mock(() => gate.promise); + const operation = f.start(); + await flush(); + f.session.decide(true); + await flush(); + f.release(); + gate.resolve(f.unlisten); + await operation; + expect(f.unlisten).toHaveBeenCalledTimes(1); + expect(f.native.execute).toHaveBeenCalledTimes(0); +}); +test("native modelReloadRequired remains actionable and cannot reach execute", async () => { + const f = fixture(); + f.native.prepare = mock(async () => { + throw new Error("plugin.build.modelReloadRequired"); + }); + await f.start(); + expect(f.session.getSnapshot().error).toContain("modelReloadRequired"); + expect(f.native.execute).toHaveBeenCalledTimes(0); +}); +test("save failure prevents synchronization and preparation", async () => { + const f = fixture(); + f.native.save = mock(async () => { + throw new Error("fixture unsaved buffer"); + }); + await f.start(); + expect(f.native.sync).toHaveBeenCalledTimes(0); + expect(f.native.prepare).toHaveBeenCalledTimes(0); + expect(f.session.getSnapshot().error).toContain("unsaved"); +}); +test("a mismatched native review is disposed rather than presented for approval", async () => { + const f = fixture(); + f.native.prepare = mock(async () => ({ ...f.review, root: "other" })); + await f.start(); + expect(f.session.getSnapshot().review).toBeNull(); + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(f.native.cancel).toHaveBeenCalledTimes(1); + expect(f.session.getSnapshot().error).toContain("staleContext"); +}); diff --git a/windows/tauri/src/features/build-systems/build-run-session.ts b/windows/tauri/src/features/build-systems/build-run-session.ts new file mode 100644 index 0000000000..a0dc21400d --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-run-session.ts @@ -0,0 +1,582 @@ +import type { StoreApi } from "zustand"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + BUILD_SYSTEMS_STORE_KEY, + buildSystemViewId, + useBuildSystemsStore, + type BuildSystemsState, + type BuildRequestVersion, +} from "./build-systems-store"; +import { buildRunNative, type BuildRunNative } from "./build-run-native"; +import type { BuildRunDefinition, BuildRunReview, BuildRunSelection } from "./build-run-types"; +import { + MAX_OUTPUT_SESSIONS, + useBuildOutputStore, + type BuildOutputState, +} from "./output/build-output-store"; +import { + buildOutputSessionId, + type BuildOperationIdentity, + type BuildOutputEvent, + type BuildOutputPort, + type BuildRunTerminal, +} from "./output/build-output-types"; + +export interface BuildRunState { + phase: + | "idle" + | "preparing" + | "review" + | "starting" + | "running" + | "stopping" + | "cancelFailed" + | "failed" + | "ended"; + review: BuildRunReview | null; + programs: unknown[]; + error: string | null; +} +interface Attempt { + controller: AbortController; + review: BuildRunReview | null; + decide: ((accepted: boolean) => void) | null; + executed: boolean; + settled: boolean; + finished: boolean; + terminalEvent: boolean; + cancellation: Promise | null; + cleanupError: string | null; + request: BuildRequestVersion | null; + sequence: number; + generation: number | null; + fingerprint: string | null | undefined; + syncDigest: string | null | undefined; + stale: boolean | undefined; + revocation: number; + syncing: boolean; + window: string; +} +const message = (error: unknown) => (error instanceof Error ? error.message : String(error)); +const identityKeys = [ + "operationId", + "pluginId", + "buildSystemId", + "workspaceRoot", + "root", + "generation", +] as const; +/** A successful invoke may arrive ahead of queued WebView output events. */ +export const OUTPUT_DRAIN_TIMEOUT_MS = 5000; +export function matchesBuildRun( + identity: BuildOperationIdentity, + value: unknown, +): value is BuildOutputEvent { + if (!value || typeof value !== "object") return false; + const row = value as BuildOutputEvent; + if (!identityKeys.every((key) => row[key] === identity[key])) return false; + switch (row.type) { + case "chunk": + return (row.stream === "stdout" || row.stream === "stderr") && typeof row.text === "string"; + case "diagnostics": + return ( + Array.isArray(row.diagnostics) && + row.diagnostics.every( + (value) => + value && + typeof value.path === "string" && + typeof value.message === "string" && + Number.isSafeInteger(value.line) && + value.line > 0 && + (value.column == null || (Number.isSafeInteger(value.column) && value.column > 0)) && + ["error", "warning", "information", "hint"].includes(value.severity), + ) + ); + case "testResults": + return ( + !!row.results && + Array.isArray(row.results.tests) && + row.results.tests.every( + (value) => + value && + typeof value.suite === "string" && + typeof value.case === "string" && + ["passed", "failed", "error", "skipped"].includes(value.status) && + (value.message == null || typeof value.message === "string") && + (value.path == null || typeof value.path === "string") && + Number.isSafeInteger(value.invocations) && + value.invocations >= 0, + ) && + !!row.results.summary && + ["total", "passed", "failed", "errors", "skipped"].every((key) => { + const value = row.results.summary[key as keyof typeof row.results.summary]; + return Number.isSafeInteger(value) && value >= 0; + }) && + typeof row.results.success === "boolean" + ); + case "terminal": + return validTerminal(row.terminal); + default: + return false; + } +} +function validTerminal(value: BuildRunTerminal): boolean { + return ( + !!value && + ["succeeded", "failed", "cancelled", "failedToStart"].includes(value.kind) && + (value.message == null || typeof value.message === "string") && + (value.exitCode == null || Number.isSafeInteger(value.exitCode)) + ); +} +async function abortable(work: Promise, signal: AbortSignal): Promise { + let abort = () => {}; + const cancelled = new Promise((_resolve, reject) => { + abort = () => reject(new Error("plugin.build.staleContext")); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + }); + try { + return await Promise.race([work, cancelled]); + } finally { + signal.removeEventListener("abort", abort); + } +} + +/** Frontend handle to one native owner. No executable resolution, process + * lifecycle, output decoding or build semantics are implemented here. A failed + * cleanup retains this handle across panel reopening so Stop remains usable. */ +export class BuildRunSession { + readonly sessionId: string; + readonly port: BuildOutputPort; + #state: BuildRunState = { phase: "idle", review: null, programs: [], error: null }; + #listeners = new Set<() => void>(); + #attempt: Attempt | null = null; + #attached = 0; + #releaseContext: (() => void) | null = null; + #releaseEvents: (() => void) | null = null; + #listening: Promise | null = null; + #eventEpoch = 0; + #finishDrain: (() => void) | null = null; + #output: StoreApi; + constructor( + readonly workspaceId: string, + readonly pluginId: string, + readonly buildSystemId: string, + readonly root: string, + private store: StoreApi, + readonly native: BuildRunNative = buildRunNative, + ) { + this.sessionId = buildOutputSessionId(pluginId, buildSystemId, root); + this.#output = useBuildOutputStore.getStore(workspaceId); + this.port = { + subscribe: async (identity, sink) => + native.listen(native.windowLabel(), (event) => { + if (matchesBuildRun(identity, event)) sink(event); + }), + cancel: (identity) => this.cancel(identity), + }; + } + getSnapshot = () => this.#state; + subscribe = (listener: () => void) => { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + }; + get canEvict() { + return !this.#attached && !this.#attempt; + } + #emit(patch: Partial) { + this.#state = { ...this.#state, ...patch }; + for (const listener of this.#listeners) listener(); + } + #instance() { + return this.store + .getState() + .windows[buildSystemViewId(this.pluginId, this.buildSystemId)]?.instances.find( + (row) => row.root === this.root, + ); + } + #current(attempt: Attempt) { + const state = this.store.getState(); + const runtime = workspaceRuntimeRegistry.getWorkspace(this.workspaceId); + return ( + !attempt.controller.signal.aborted && + this.#attached > 0 && + this.native.windowLabel() === attempt.window && + workspaceRuntimeRegistry.getActiveWorkspaceId() === this.workspaceId && + runtime?.stores.get(BUILD_SYSTEMS_STORE_KEY) === this.store && + runtime.status === "ready" && + runtime.descriptor.path === state.workspaceRoot && + !state.revokedPlugins[this.pluginId] && + (state.pluginRevocationEpochs[this.pluginId] ?? 0) === attempt.revocation && + state.windows[buildSystemViewId(this.pluginId, this.buildSystemId)]?.selectedRoot === + this.root && + state.requestSequence === attempt.sequence && + (attempt.syncing || + (state.generation === attempt.generation && + this.#instance()?.optionFingerprint === attempt.fingerprint && + this.#instance()?.syncDigest === attempt.syncDigest && + this.#instance()?.stale === attempt.stale)) + ); + } + attach(): () => void { + this.#attached += 1; + if (this.#attached === 1) { + const check = () => { + const attempt = this.#attempt; + if (attempt && !attempt.controller.signal.aborted && !this.#current(attempt)) { + this.#closeEvents(); + void this.cancel().catch(() => {}); + } + const generation = this.store.getState().generation; + if (generation !== null) this.#output.getState().actions.invalidateGeneration(generation); + }; + const releaseStore = this.store.subscribe(check); + const releaseRegistry = workspaceRuntimeRegistry.subscribe(check); + this.#releaseContext = () => { + releaseStore(); + releaseRegistry(); + }; + const session = this.#output.getState().sessions[this.sessionId]; + if (session?.status === "running") + void this.#listen(session.identity).catch((error) => + this.#output + .getState() + .actions.subscriptionFailed(this.sessionId, session.identity, message(error)), + ); + } + let released = false; + return () => { + if (released) return; + released = true; + if (--this.#attached) return; + this.#releaseContext?.(); + this.#releaseContext = null; + this.#closeEvents(); + if (this.#attempt) void this.cancel().catch(() => {}); + }; + } + #closeEvents() { + this.#finishDrain?.(); + this.#eventEpoch += 1; + this.#releaseEvents?.(); + this.#releaseEvents = null; + this.#listening = null; + } + async #listen(identity: BuildOperationIdentity): Promise { + if (this.#releaseEvents) return; + if (this.#listening) return this.#listening; + const epoch = this.#eventEpoch; + const windowLabel = this.native.windowLabel(); + this.#listening = this.native + .listen(windowLabel, (event) => { + if ( + epoch !== this.#eventEpoch || + !this.#attached || + this.native.windowLabel() !== windowLabel || + !matchesBuildRun(identity, event) + ) + return; + const actions = this.#output.getState().actions; + switch (event.type) { + case "chunk": + actions.appendChunk(this.sessionId, event, event.stream, event.text); + break; + case "diagnostics": + actions.applyDiagnostics(this.sessionId, event, event.diagnostics); + break; + case "testResults": + actions.applyTestResults(this.sessionId, event, event.results); + break; + case "terminal": + actions.complete(this.sessionId, event, event.terminal); + if (this.#attempt?.review?.operationId === identity.operationId) { + const attempt = this.#attempt; + attempt.terminalEvent = true; + attempt.finished = true; + if (attempt.settled) { + if (attempt.request) this.store.getState().actions.cancelRequest(attempt.request); + this.#attempt = null; + this.#closeEvents(); + this.#emit({ phase: "ended", review: null, error: null }); + } + } + this.#finishDrain?.(); + break; + } + }) + .then((release) => { + if (epoch !== this.#eventEpoch || !this.#attached) release(); + else this.#releaseEvents = release; + }); + return this.#listening; + } + async #drainEvents(attempt: Attempt): Promise { + if (attempt.terminalEvent || !this.#attached) return; + await new Promise((resolve) => { + let cancelTimer = () => {}; + const finish = () => { + cancelTimer(); + this.#finishDrain = null; + resolve(); + }; + this.#finishDrain = finish; + const timeout = () => { + if (attempt.review) + this.#output + .getState() + .actions.subscriptionFailed( + this.sessionId, + attempt.review, + "plugin.build.outputUnavailable", + ); + finish(); + }; + const schedule = + this.native.scheduleOutputDrain ?? + ((callback: () => void, milliseconds: number) => { + const timer = setTimeout(callback, milliseconds); + return () => clearTimeout(timer); + }); + cancelTimer = schedule(timeout, OUTPUT_DRAIN_TIMEOUT_MS); + }); + } + decide(accepted: boolean) { + const decide = this.#attempt?.decide; + if (this.#attempt) this.#attempt.decide = null; + if (decide) this.#emit({ review: null, phase: accepted ? "starting" : "stopping" }); + decide?.(accepted); + } + async start(selection: BuildRunSelection): Promise { + if (this.#attempt || this.#output.getState().sessions[this.sessionId]?.status === "running") + return; + const state = this.store.getState(); + const attempt: Attempt = { + controller: new AbortController(), + review: null, + decide: null, + executed: false, + settled: false, + finished: false, + terminalEvent: false, + cancellation: null, + cleanupError: null, + request: null, + sequence: state.requestSequence, + generation: state.generation, + fingerprint: this.#instance()?.optionFingerprint, + syncDigest: this.#instance()?.syncDigest, + stale: this.#instance()?.stale, + revocation: state.pluginRevocationEpochs[this.pluginId] ?? 0, + syncing: false, + window: this.native.windowLabel(), + }; + this.#attempt = attempt; + this.#emit({ phase: "preparing", error: null, review: null, programs: [] }); + const current = () => { + if (!this.#current(attempt)) throw new Error("plugin.build.staleContext"); + }; + try { + current(); + await abortable(this.native.save(this.workspaceId), attempt.controller.signal); + current(); + attempt.syncing = true; + attempt.sequence += 1; + await abortable( + this.native.sync(this.pluginId, this.buildSystemId, this.root), + attempt.controller.signal, + ); + attempt.generation = this.store.getState().generation; + attempt.fingerprint = this.#instance()?.optionFingerprint; + attempt.syncDigest = this.#instance()?.syncDigest; + attempt.stale = this.#instance()?.stale; + attempt.syncing = false; + current(); + // Reuse the build store's busy barrier for explicit edits/model loads. + // Passive fingerprint observations still run while busy, and invalidate + // consent only when the source context actually changed. + attempt.sequence += 1; + attempt.request = this.store.getState().actions.beginRequest(); + current(); + const definition: BuildRunDefinition = await this.native.definition( + this.pluginId, + this.buildSystemId, + ); + current(); + // A late prepare may allocate a native review after unmount. Always + // retain its ID and cancel it; never race this await against disposal. + const review = await this.native.prepare( + this.store.getState().workspaceRoot!, + this.pluginId, + this.buildSystemId, + this.root, + selection, + ); + attempt.review = review; + current(); + if ( + review.pluginId !== this.pluginId || + review.buildSystemId !== this.buildSystemId || + review.root !== this.root || + review.workspaceRoot !== this.store.getState().workspaceRoot || + review.generation !== attempt.generation || + review.kind !== selection.kind || + !review.operationId + ) + throw new Error("plugin.build.staleContext"); + this.#emit({ phase: "review", review, programs: definition.programs }); + const accepted = await new Promise((resolve) => { + attempt.decide = resolve; + }); + attempt.decide = null; + if (!accepted) return; + current(); + const identity: BuildOperationIdentity = { ...review, title: review.title }; + if (!this.#output.getState().actions.beginOperation(this.sessionId, identity)) + throw new Error("plugin.build.busy"); + this.#closeEvents(); + await this.#listen(identity); + current(); + // A file notification may still be in the watcher's debounce window. + // Acquire the output listener first, then recheck the native fingerprint + // immediately before execution. Preserve the approved snapshot rather + // than adopting a changed model or option state. This check never + // executes project/tool code. + attempt.syncing = true; + attempt.sequence += 1; + await abortable( + this.native.sync(this.pluginId, this.buildSystemId, this.root), + attempt.controller.signal, + ); + attempt.syncing = false; + if (!attempt.stale && this.#instance()?.stale) + throw new Error("plugin.build.modelReloadRequired"); + current(); + attempt.sequence += 1; + attempt.request = this.store.getState().actions.beginRequest(); + current(); + this.#emit({ phase: "running", review: null }); + attempt.executed = true; + const terminal = await this.native.execute(review.operationId); + if (!validTerminal(terminal)) throw new Error("plugin.build.invalidOutput"); + attempt.finished = true; + await this.#drainEvents(attempt); + // invoke's result is authoritative after cleanup, even when the panel + // detached before the final event. Store identity guards a replacement. + if ( + workspaceRuntimeRegistry + .getWorkspace(this.workspaceId) + ?.stores.get(BUILD_SYSTEMS_STORE_KEY) === this.store + ) + this.#output.getState().actions.complete(this.sessionId, identity, terminal); + } catch (error) { + if (attempt.executed && attempt.review && !attempt.finished) { + this.#output.getState().actions.stopFailed(this.sessionId, attempt.review, message(error)); + this.#emit({ phase: "cancelFailed", review: null, error: message(error) }); + return; // An execute rejection cannot prove native cleanup. + } + if (!attempt.executed && !attempt.controller.signal.aborted) + this.#emit({ phase: "failed", review: null, error: message(error) }); + } finally { + attempt.settled = true; + attempt.controller.abort(); + attempt.decide?.(false); + attempt.decide = null; + if (!attempt.executed && attempt.review && !attempt.cleanupError) { + try { + await this.#cancelNative(attempt); + } catch { + /* cancel() retained the error and native owner for retry. */ + } + } + const retained = + !attempt.finished && (attempt.executed || this.#state.phase === "cancelFailed"); + if (!retained && this.#attempt === attempt) { + if (attempt.request) this.store.getState().actions.cancelRequest(attempt.request); + this.#attempt = null; + this.#closeEvents(); + this.#emit({ + review: null, + phase: this.#state.phase === "failed" ? "failed" : "ended", + error: this.#state.phase === "failed" ? this.#state.error : null, + }); + } + } + } + async #cancelNative(attempt: Attempt): Promise { + if (!attempt.review || attempt.finished) return; + if (attempt.cancellation) return attempt.cancellation; + const review = attempt.review; + attempt.cancellation = Promise.resolve() + .then(() => this.native.cancel(review.operationId)) + .catch((error) => { + attempt.cancellation = null; + attempt.cleanupError = message(error); + if (this.#attempt === attempt && !attempt.finished) { + this.#output.getState().actions.stopFailed(this.sessionId, review, message(error)); + this.#emit({ phase: "cancelFailed", review: null, error: message(error) }); + } + throw error; + }); + await attempt.cancellation; + attempt.cancellation = null; + if (this.#attempt !== attempt) return; + if (!attempt.executed || attempt.settled) { + attempt.finished = true; + // A review never executed, or execute already rejected. This native + // cancel result confirms cleanup; a pending execute still owns its result. + this.#output.getState().actions.complete(this.sessionId, review, { kind: "cancelled" }); + if (attempt.request) this.store.getState().actions.cancelRequest(attempt.request); + if (this.#attempt === attempt) this.#attempt = null; + this.#closeEvents(); + this.#emit({ + phase: this.#state.phase === "failed" ? "failed" : "ended", + review: null, + error: this.#state.phase === "failed" ? this.#state.error : null, + }); + } + } + async cancel(identity?: BuildOperationIdentity): Promise { + const attempt = this.#attempt; + if ( + identity && + (!attempt?.review || !identityKeys.every((key) => attempt.review![key] === identity[key])) + ) + throw new Error("plugin.build.staleContext"); + if (!attempt) return; + attempt.cleanupError = null; + attempt.controller.abort(); + attempt.decide?.(false); + attempt.decide = null; + this.#emit({ phase: "stopping", review: null, error: null }); + if (attempt.review) this.#output.getState().actions.beginStop(this.sessionId, attempt.review); + if (attempt.review) await this.#cancelNative(attempt); + } +} + +// Bounded UI handles, keyed by the actual workspace store. Native owns the +// operations; retained handles exist solely to retry a failed cleanup. +const sessions = new WeakMap, Map>(); +export function getBuildRunSession( + workspaceId: string, + pluginId: string, + buildSystemId: string, + root: string, + native: BuildRunNative = buildRunNative, +): BuildRunSession { + const store = useBuildSystemsStore.getStore(workspaceId); + const rows = sessions.get(store) ?? new Map(); + sessions.set(store, rows); + const key = buildOutputSessionId(pluginId, buildSystemId, root); + const existing = rows.get(key); + if (existing) return existing; + if (rows.size >= MAX_OUTPUT_SESSIONS) { + const free = [...rows].find(([, session]) => session.canEvict); + if (!free) throw new Error("plugin.build.busy"); + rows.delete(free[0]); + } + const session = new BuildRunSession(workspaceId, pluginId, buildSystemId, root, store, native); + rows.set(key, session); + return session; +} diff --git a/windows/tauri/src/features/build-systems/build-run-types.ts b/windows/tauri/src/features/build-systems/build-run-types.ts new file mode 100644 index 0000000000..2472c32bab --- /dev/null +++ b/windows/tauri/src/features/build-systems/build-run-types.ts @@ -0,0 +1,35 @@ +import type { BuildModelReview } from "./build-systems-types"; + +/** Exact camelCase serde selection of build::run::BuildRunSelection. */ +export interface BuildRunSelection { + kind: "task" | "test"; + taskId: string | null; + projectId: string | null; + targetId: string | null; + modelTaskId: string | null; + suite: string | null; + case: string | null; + userArguments: string[]; +} +export interface BuildRunReview extends BuildModelReview { + title: string; + kind: "task" | "test"; +} +export type BuildRunScope = "workspace" | "project" | "target"; +export interface BuildTaskDeclaration { + id: string; + displayName: string; + execution?: "task" | "application" | "service"; + scopes?: BuildRunScope[]; + targetTags?: string[]; + fromModel?: boolean; + requiresUserArguments?: boolean; + category?: string[]; + group?: string; +} +export interface BuildRunDefinition { + tasks: BuildTaskDeclaration[]; + test?: { scopes: BuildRunScope[]; targetTags?: string[]; filters?: ("suite" | "case")[] } | null; + /** Full Core-reviewed program declarations, never a guessed command/path. */ + programs: unknown[]; +} diff --git a/windows/tauri/src/features/build-systems/build-systems-service.test.ts b/windows/tauri/src/features/build-systems/build-systems-service.test.ts index 7ab5d86cbf..5cf3ea383e 100644 --- a/windows/tauri/src/features/build-systems/build-systems-service.test.ts +++ b/windows/tauri/src/features/build-systems/build-systems-service.test.ts @@ -107,6 +107,9 @@ const { useBuildSystemsCatalogStore, } = buildSystemsService; const { useBuildSystemsStore } = await import("./build-systems-store"); +const { BuildRunSession } = await import("./build-run-session"); +type BuildRunNative = import("./build-run-native").BuildRunNative; +type BuildRunTerminal = import("./output/build-output-types").BuildRunTerminal; const WORKSPACE_A = "C:/work/a"; const WORKSPACE_B = "C:/work/b"; @@ -180,6 +183,179 @@ function cargoView(workspaceRoot: string, generation: number): BuildView { }; } +describe("file changes during task consent and execution", () => { + async function runningFixture() { + resetState(); + activateWorkspace("ws-a", WORKSPACE_A); + const initial = cargoView(WORKSPACE_A, 1); + workspaceRuntimeRegistry.updateWorkspaceStatus("ws-a", "ready"); + initial.instances[0]!.modelSource = "command"; + initial.instances[0]!.syncDigest = "approved-source"; + nextResult = initial; + const store = useBuildSystemsStore.getStore("ws-a"); + store.getState().actions.applyView(initial, store.getState().actions.beginRequest()); + const terminal = deferred(); + const native: BuildRunNative = { + windowLabel: () => "test-window", + save: async () => {}, + sync: buildSystemsService.syncInstance, + definition: async () => ({ + tasks: [{ id: "build", displayName: "Build", scopes: ["workspace"] }], + programs: [], + }), + prepare: async () => ({ + operationId: "native-watched-run", + pluginId: catalogEntry.pluginId, + buildSystemId: "cargo", + workspaceRoot: WORKSPACE_A, + root: ".", + generation: 1, + kind: "task", + title: "Build", + executesProjectCode: true, + mayUseNetwork: true, + }), + execute: mock(async () => terminal.promise as Promise), + cancel: mock(async () => { + terminal.resolve({ kind: "cancelled" }); + }), + listen: async () => () => {}, + scheduleOutputDrain: (callback) => { + callback(); + return () => {}; + }, + }; + const session = new BuildRunSession("ws-a", catalogEntry.pluginId, "cargo", ".", store, native); + const releaseSession = session.attach(); + cleanups.push(() => { + session.decide(false); + releaseSession(); + }); + let scheduled: (() => void) | undefined; + const releaseWatcher = await subscribeBuildSyncWatcher((callback) => { + scheduled = callback; + return () => { + scheduled = undefined; + }; + }); + cleanups.push(releaseWatcher); + const run = session.start({ + kind: "task", + taskId: "build", + projectId: null, + targetId: null, + modelTaskId: null, + suite: null, + case: null, + userArguments: [], + }); + await flushMicrotasks(); + expect(session.getSnapshot()).toEqual( + expect.objectContaining({ phase: "review", error: null }), + ); + const change = async (changed: boolean) => { + nextResult = changed + ? { + ...initial, + instances: initial.instances.map((row) => ({ + ...row, + stale: true, + syncDigest: "edited-source", + })), + } + : initial; + fileChangeHandler!({ payload: { path: "Cargo.toml", event_type: "reloaded" } }); + expect(scheduled).toBeDefined(); + scheduled!(); + await flushMicrotasks(); + }; + return { initial, native, session, store, terminal, run, change }; + } + + test("unchanged save notifications preserve consent and the active run", async () => { + const f = await runningFixture(); + await f.change(false); + expect(f.session.getSnapshot().phase).toBe("review"); + expect(f.native.cancel).toHaveBeenCalledTimes(0); + f.session.decide(true); + await flushMicrotasks(); + expect(f.native.execute).toHaveBeenCalledTimes(1); + await f.change(false); + expect(f.session.getSnapshot().phase).toBe("running"); + expect(f.native.cancel).toHaveBeenCalledTimes(0); + f.terminal.resolve({ kind: "succeeded", exitCode: 0 }); + await f.run; + }); + + test("a watched fingerprint change cancels open consent before execution", async () => { + const f = await runningFixture(); + await f.change(true); + f.session.decide(true); + await f.run; + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(f.native.cancel).toHaveBeenCalledWith("native-watched-run"); + expect( + f.store.getState().windows[`build-system:${catalogEntry.pluginId}:cargo`]!.instances[0]! + .stale, + ).toBe(true); + }); + + test("a watched fingerprint change also cancels an executing native owner", async () => { + const f = await runningFixture(); + f.session.decide(true); + await flushMicrotasks(); + expect(f.native.execute).toHaveBeenCalledTimes(1); + await f.change(true); + await f.run; + expect(f.native.cancel).toHaveBeenCalledWith("native-watched-run"); + }); + + test("approval rechecks native freshness before a debounced event is delivered", async () => { + const f = await runningFixture(); + nextResult = { + ...f.initial, + instances: f.initial.instances.map((row) => ({ + ...row, + stale: true, + syncDigest: "edited-before-watch", + })), + }; + // Deliberately leave the real file event's scheduled callback pending. + fileChangeHandler!({ payload: { path: "Cargo.toml", event_type: "reloaded" } }); + f.session.decide(true); + await f.run; + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(f.native.cancel).toHaveBeenCalledWith("native-watched-run"); + }); + + test("a file change while acquiring the output listener is checked before execution", async () => { + const f = await runningFixture(); + const listener = deferred(); + f.native.listen = mock(async () => listener.promise as Promise<() => void>); + f.session.decide(true); + await flushMicrotasks(); + expect(f.native.listen).toHaveBeenCalledTimes(1); + expect(f.native.execute).toHaveBeenCalledTimes(0); + nextResult = { + ...f.initial, + instances: f.initial.instances.map((row) => ({ + ...row, + stale: true, + syncDigest: "edited-during-listener-acquisition", + })), + }; + // The notification remains debounced while native listener acquisition + // completes. Its delivery must not be necessary to reject the old plan. + fileChangeHandler!({ payload: { path: "Cargo.toml", event_type: "reloaded" } }); + listener.resolve(() => {}); + await flushMicrotasks(); + f.terminal.resolve({ kind: "succeeded", exitCode: 0 }); + await f.run; + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(f.native.cancel).toHaveBeenCalledWith("native-watched-run"); + }); +}); + describe("refreshBuildSystems", () => { test("forwards the workspace root without a forgeable window label", async () => { resetState(); diff --git a/windows/tauri/src/features/build-systems/build-systems-service.ts b/windows/tauri/src/features/build-systems/build-systems-service.ts index fa419d8519..00e653f4a4 100644 --- a/windows/tauri/src/features/build-systems/build-systems-service.ts +++ b/windows/tauri/src/features/build-systems/build-systems-service.ts @@ -387,14 +387,29 @@ export async function setBuildOption( if (view && isCurrent()) store.getState().actions.applyView(view, sequence); } -async function syncInstance(pluginId: string, buildSystemId: string, root: string): Promise { - const { store, workspaceRoot, isCurrent } = initiatingStore(); +export async function syncInstance( + pluginId: string, + buildSystemId: string, + root: string, + options: { observeOnly?: boolean } = {}, +): Promise { + const { workspaceId, store, workspaceRoot, isCurrent } = initiatingStore(); if (!workspaceRoot) { return; } - const sequence = store.getState().actions.beginRequest(); + const initial = store.getState(); + const sequence = options.observeOnly + ? { sequence: initial.requestSequence, revocationEpoch: initial.revocationEpoch } + : initial.actions.beginRequest(); + const windowId = buildSystemViewId(pluginId, buildSystemId); + const observationCurrent = () => + isCurrent() && + workspaceRuntimeRegistry.getWorkspace(workspaceId)?.stores.get(BUILD_SYSTEMS_STORE_KEY) === + store && + store.getState().requestSequence === sequence.sequence && + store.getState().revocationEpoch === sequence.revocationEpoch; const view = await enqueueRegistryOperation(() => - isCurrent() + (options.observeOnly ? observationCurrent() : isCurrent()) ? invoke("declarative_plugins_build_sync_check", { workspaceRoot, pluginId, @@ -403,10 +418,40 @@ async function syncInstance(pluginId: string, buildSystemId: string, root: strin }) : Promise.resolve(null), ).catch((error: unknown) => { - if (isCurrent()) store.getState().actions.fail(failureMessage(error), sequence); + if (options.observeOnly) { + if (observationCurrent()) { + const invalidation = store.getState().actions.beginRequest(); + store.getState().actions.fail(failureMessage(error), invalidation); + } + } else if (isCurrent()) store.getState().actions.fail(failureMessage(error), sequence); throw error; }); - if (view && isCurrent()) store.getState().actions.applyView(view, sequence); + if (!view || !isCurrent()) return; + if (!options.observeOnly) { + store.getState().actions.applyView(view, sequence); + return; + } + if (!observationCurrent()) return; + const current = store.getState(); + const original = current.windows[windowId]?.instances.find((row) => row.root === root); + const updated = view.instances.find( + (row) => row.pluginID === pluginId && row.buildSystemID === buildSystemId && row.root === root, + ); + // Own saves and generated output also notify the file watcher. Checking a + // fingerprint is passive; only an actual native context change invalidates + // consent, so unchanged notifications must not cancel their own build. + if ( + view.workspaceRoot === workspaceRoot && + view.generation === current.generation && + updated?.syncDigest === original?.syncDigest && + updated?.stale === original?.stale && + updated?.optionFingerprint === original?.optionFingerprint && + JSON.stringify(updated?.modelError) === JSON.stringify(original?.modelError) && + !!updated === !!original + ) + return; + const invalidation = store.getState().actions.beginRequest(); + store.getState().actions.applyView(view, invalidation); } /** @@ -431,12 +476,14 @@ export async function subscribeBuildSyncWatcher( // Read the active workspace's store at fire time; a workspace switch // during the debounce checks the new workspace's instances only. const state = useBuildSystemsStore.getState(); - if (state.status !== "ready") { + if (!state.workspaceRoot || state.generation === null) { return; } for (const window of Object.values(state.windows)) { for (const instance of window.instances) { - syncInstance(instance.pluginID, instance.buildSystemID, instance.root).catch(() => { + syncInstance(instance.pluginID, instance.buildSystemID, instance.root, { + observeOnly: true, + }).catch(() => { // A failed sync (workspace mid-switch, plugin disabled) keeps the // last state; the next change or manual refresh retries. }); diff --git a/windows/tauri/src/features/build-systems/build-systems-types.ts b/windows/tauri/src/features/build-systems/build-systems-types.ts index 6960006f23..c9ef5a7605 100644 --- a/windows/tauri/src/features/build-systems/build-systems-types.ts +++ b/windows/tauri/src/features/build-systems/build-systems-types.ts @@ -20,6 +20,9 @@ export interface BuildOptionDescription { } export interface BuildModelTarget { + id?: string; + projectID?: string; + path?: string; name: string; tags?: string[]; } @@ -29,9 +32,15 @@ export interface BuildModelTask { name: string; group?: string; category?: string[]; + projectID?: string; + template?: string; + execution?: "task" | "application" | "service"; + displayName?: string; } export interface BuildModelProject { + id?: string; + displayName?: string; name: string; path: string; properties?: Record; @@ -52,7 +61,12 @@ export interface BuildInstanceView { root: string; markers: string[]; modelSource: "static" | "command" | "hostIntegration" | "provider" | null; - model?: { projects?: BuildModelProject[]; truncated?: boolean } | null; + model?: { + projects?: BuildModelProject[]; + targets?: BuildModelTarget[]; + tasks?: BuildModelTask[]; + truncated?: boolean; + } | null; modelError?: BuildModelError | null; stale: boolean; syncDigest: string | null; diff --git a/windows/tauri/src/features/build-systems/components/build-model-controls.test.tsx b/windows/tauri/src/features/build-systems/components/build-model-controls.test.tsx index 1f31d742e0..be10303a24 100644 --- a/windows/tauri/src/features/build-systems/components/build-model-controls.test.tsx +++ b/windows/tauri/src/features/build-systems/components/build-model-controls.test.tsx @@ -9,12 +9,26 @@ import { buildSystemViewId, useBuildSystemsStore } from "../build-systems-store" import { BuildModelCancellationError } from "../build-systems-service"; import type { BuildModelLoadResult, BuildModelReview, BuildView } from "../build-systems-types"; import type { BuildModelService } from "./build-model-controls"; +import type { BuildRunNative } from "../build-run-native"; const restoreDom = installHappyDom(); const { createRoot } = await import("react-dom/client"); const { default: BuildSystemPane } = await import("./build-system-pane"); afterAll(restoreDom); const cleanups: Array<() => void | Promise> = []; +const noRun = async (): Promise => { + throw new Error("unexpected task/test execution in model-only test"); +}; +const passiveRunNative: BuildRunNative = { + windowLabel: () => "workspace-model-tests", + definition: async () => ({ tasks: [], programs: [] }), + save: noRun, + sync: noRun, + prepare: noRun, + execute: noRun, + cancel: noRun, + listen: noRun, +}; afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); workspaceRuntimeRegistry.resetForTests(); @@ -155,7 +169,12 @@ async function mount( await act(async () => root.render( - {}} modelService={fake.service} /> + {}} + modelService={fake.service} + runNative={passiveRunNative} + /> , ), ); diff --git a/windows/tauri/src/features/build-systems/components/build-run-controls.test.tsx b/windows/tauri/src/features/build-systems/components/build-run-controls.test.tsx new file mode 100644 index 0000000000..3ea9ed0ebd --- /dev/null +++ b/windows/tauri/src/features/build-systems/components/build-run-controls.test.tsx @@ -0,0 +1,486 @@ +import { afterAll, afterEach, expect, mock, spyOn, test } from "bun:test"; +import { act } from "react"; +import * as tauri from "@/platform/tauri-core"; +import * as events from "@tauri-apps/api/event"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { buildSystemViewId, useBuildSystemsStore } from "../build-systems-store"; +import { useBuildOutputStore } from "../output/build-output-store"; +import type { BuildRunNative } from "../build-run-native"; +import type { BuildRunDefinition, BuildRunReview } from "../build-run-types"; +import type { BuildView } from "../build-systems-types"; +import type { BuildOutputEvent, BuildRunTerminal } from "../output/build-output-types"; + +const restoreDom = installHappyDom(); +const { useFileSystemStore } = await import("@/features/file-system/stores/file-system.store"); +const { createRoot } = await import("react-dom/client"); +const { default: BuildSystemPane } = await import("./build-system-pane"); +afterAll(restoreDom); +const cleanups: Array<() => void | Promise> = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); + workspaceRuntimeRegistry.resetForTests(); +}); +function deferred(fallback: T) { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + cleanups.push(() => resolve(fallback)); + return { promise, resolve, reject }; +} +const ROOT = "D:/fixture/run-ui"; +const PLUGIN = "dev.lithe.fixture.run-ui"; +const VIEW = buildSystemViewId(PLUGIN, "fixture"); +function view(): BuildView { + return { + generation: 3, + workspaceRoot: ROOT, + issues: [], + instances: [ + { + pluginID: PLUGIN, + buildSystemID: "fixture", + displayName: "Fixture", + root: ".", + markers: [], + modelSource: "command", + model: { + projects: [ + { id: "project-app", name: "App", path: "app" }, + { id: "project-other", name: "Other", path: "other" }, + ], + targets: [ + { + id: "test-target", + projectID: "project-app", + name: "Integration target", + path: "app", + tags: ["test", "integration", "smoke"], + }, + { + id: "library-target", + projectID: "project-app", + name: "Library target", + tags: ["library"], + }, + { + id: "other-target", + projectID: "project-other", + name: "Other project target", + tags: ["test"], + }, + ], + tasks: [ + { + id: "model-task", + projectID: "project-app", + name: "verifyEverything", + displayName: "Verify everything", + template: "from-model", + execution: "task", + category: ["Checks", "Extended"], + }, + { + id: "background", + projectID: "project-app", + name: "Background service", + template: "from-model", + execution: "service", + }, + ], + }, + stale: false, + syncDigest: "sync", + optionFingerprint: "options", + options: [], + }, + ], + }; +} +const definition: BuildRunDefinition = { + tasks: [ + { + id: "build", + displayName: "Build fixture", + scopes: ["workspace", "project", "target"], + targetTags: ["test"], + execution: "task", + category: ["Lifecycle"], + }, + { id: "from-model", displayName: "Model tasks", fromModel: true }, + { + id: "application", + displayName: "Application entry", + execution: "application", + scopes: ["workspace"], + }, + { id: "service", displayName: "Service entry", execution: "service", scopes: ["workspace"] }, + ], + test: { + scopes: ["workspace", "project", "target"], + targetTags: ["test"], + filters: ["suite", "case"], + }, + programs: [ + { + kind: "buildSystem", + buildSystemID: "fixture", + invocation: { + executables: [ + { kind: "projectFile", path: "fixture-wrapper.cmd" }, + { kind: "command", command: "fixture-tool" }, + ], + }, + tasks: [{ id: "build", arguments: ["build", "${userArguments}"] }], + }, + ], +}; +let nextId = 0; +function press(element: HTMLElement) { + element.dispatchEvent( + new window.PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse", button: 0 }), + ); + element.dispatchEvent(new window.MouseEvent("mousedown", { bubbles: true, button: 0 })); + element.dispatchEvent(new window.MouseEvent("mouseup", { bubbles: true, button: 0 })); + element.dispatchEvent(new window.MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); +} +function openSelect(element: HTMLElement) { + element.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); +} +async function mount(initial = view(), language: "en-US" | "zh-CN" = "en-US") { + const workspaceId = `run-ui-${++nextId}`; + workspaceRuntimeRegistry.activateWorkspace( + { id: workspaceId, name: "Fixture", path: ROOT }, + "ready", + ); + const store = useBuildSystemsStore.getStore(workspaceId); + store.getState().actions.applyView(initial, store.getState().actions.beginRequest()); + useBuildOutputStore.getStore(workspaceId); + const review: BuildRunReview = { + operationId: `native-${nextId}`, + pluginId: PLUGIN, + buildSystemId: "fixture", + workspaceRoot: ROOT, + root: ".", + generation: 3, + executesProjectCode: true, + mayUseNetwork: true, + kind: "task", + title: "Build fixture", + }; + const completion = deferred({ kind: "cancelled" }); + const sinks: Array<(event: BuildOutputEvent) => void> = []; + const unlisten = mock(() => {}); + const native: BuildRunNative = { + windowLabel: () => "workspace-run-ui", + definition: mock(async () => definition), + save: mock(async () => {}), + sync: mock(async () => { + const actions = store.getState().actions; + actions.applyView(initial, actions.beginRequest()); + }), + prepare: mock(async (_workspace, _plugin, _system, _root, selection) => ({ + ...review, + kind: selection.kind, + })), + execute: mock(async () => { + const result = await completion.promise; + sinks[sinks.length - 1]?.({ ...review, type: "terminal", terminal: result }); + return result; + }), + cancel: mock(async () => {}), + listen: mock(async (_window, sink) => { + sinks.push(sink); + return unlisten; + }), + }; + const invoke = spyOn(tauri, "invoke").mockImplementation( + async (command: string): Promise => { + if (command === "declarative_plugins_build_catalog") return [] as T; + if (command === "declarative_plugins_build_refresh") return initial as T; + throw new Error(`unexpected native command ${command}`); + }, + ); + const listen = spyOn(events, "listen").mockResolvedValue(() => {}); + cleanups.push(() => { + invoke.mockRestore(); + listen.mockRestore(); + }); + const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previous = env.IS_REACT_ACT_ENVIRONMENT; + env.IS_REACT_ACT_ENVIRONMENT = true; + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + cleanups.push(async () => { + await act(async () => root.unmount()); + host.remove(); + if (previous === undefined) delete env.IS_REACT_ACT_ENVIRONMENT; + else env.IS_REACT_ACT_ENVIRONMENT = previous; + }); + const render = () => + root.render( + + {}} runNative={native} /> + , + ); + await act(async () => render()); + const button = (text: string) => + [...document.querySelectorAll("button")].find( + (row) => row.textContent === text, + )!; + const choose = async (label: string, option: string) => { + await act(async () => + openSelect(host.querySelector(`[aria-label="${label}"]`)!), + ); + const row = [...document.querySelectorAll('[role="option"]')].find( + (value) => value.textContent === option, + )!; + expect(row).toBeDefined(); + await act(async () => press(row)); + }; + const input = async (label: string, value: string) => { + const element = host.querySelector( + `[aria-label="${label}"]`, + )!; + const prototype = + element.tagName === "TEXTAREA" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + await act(async () => { + Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(element, value); + element.dispatchEvent( + new window.InputEvent("input", { bubbles: true, inputType: "insertText" }), + ); + }); + }; + const prepare = async () => { + await act(async () => + button(language === "en-US" ? "Prepare task / test…" : "准备任务 / 测试…").click(), + ); + }; + const send = async (event: Partial) => { + await act(async () => sinks[sinks.length - 1]!({ ...review, ...event } as BuildOutputEvent)); + }; + return { + host, + root, + render, + native, + review, + completion, + store, + workspaceId, + button, + choose, + input, + prepare, + send, + unlisten, + }; +} + +test("production tool window displays flat model IDs and complete tags; passive selection never executes", async () => { + const f = await mount(); + expect(f.host.textContent).toContain("Integration target"); + expect(f.host.textContent).toContain("[test, integration, smoke]"); + expect(f.native.save).toHaveBeenCalledTimes(0); + expect(f.native.prepare).toHaveBeenCalledTimes(0); + await f.choose("Action", "Lifecycle / Build fixture"); + await f.choose("Scope", "Target"); + await f.choose("Project", "App (app)"); + await act(async () => + openSelect(f.host.querySelector('[aria-label="Target"]')!), + ); + const choices = [...document.querySelectorAll('[role="option"]')].map( + (row) => row.textContent, + ); + expect(choices).toContain("Integration target [test, integration, smoke]"); + expect(choices).not.toContain("Library target [library]"); + expect(choices).not.toContain("Other project target [test]"); + await act(async () => + press( + [...document.querySelectorAll('[role="option"]')].find( + (row) => row.textContent === "Integration target [test, integration, smoke]", + )!, + ), + ); + expect(f.native.prepare).toHaveBeenCalledTimes(0); + expect(f.native.execute).toHaveBeenCalledTimes(0); +}); +test("task preparation uses selected native IDs, literal arguments and full program approval before live output", async () => { + const f = await mount(); + await f.choose("Action", "Lifecycle / Build fixture"); + await f.choose("Scope", "Target"); + await f.choose("Project", "App (app)"); + await f.choose("Target", "Integration target [test, integration, smoke]"); + await f.input("Arguments (one literal argument per line)", "--fixture\nvalue with spaces"); + await f.prepare(); + expect(f.native.prepare).toHaveBeenCalledWith(ROOT, PLUGIN, "fixture", ".", { + kind: "task", + taskId: "build", + projectId: "project-app", + targetId: "test-target", + modelTaskId: null, + suite: null, + case: null, + userArguments: ["--fixture", "value with spaces"], + }); + expect(f.native.execute).toHaveBeenCalledTimes(0); + const dialog = document.querySelector('[role="dialog"]')!; + for (const text of [ + PLUGIN, + ROOT, + f.review.operationId, + "fixture-wrapper.cmd", + "${userArguments}", + "may execute code", + "may access the network", + ]) + expect(dialog.textContent).toContain(text); + await act(async () => f.button("Allow and execute").click()); + expect(f.native.execute).toHaveBeenCalledTimes(1); + expect(f.native.listen).toHaveBeenCalledTimes(1); + await f.send({ type: "chunk", stream: "stderr", text: "native build warning\n" }); + await act(async () => + f.completion.resolve({ kind: "failed", exitCode: 1, message: "fixture compiler failed" }), + ); + expect(f.host.textContent).toContain("native build warning"); + expect(f.host.textContent).toContain("fixture compiler failed"); +}); +test("test suite/case selection delivers decoded failures and jumps through the existing source opener", async () => { + const f = await mount(); + await f.choose("Action", "Tests"); + await f.choose("Scope", "Target"); + await f.choose("Project", "App (app)"); + await f.choose("Target", "Integration target [test, integration, smoke]"); + await f.input("Test suite filter", "fixture.Suite"); + await f.input("Test case filter", "failsCase"); + await f.prepare(); + expect(f.native.prepare).toHaveBeenCalledWith( + ROOT, + PLUGIN, + "fixture", + ".", + expect.objectContaining({ + kind: "test", + taskId: null, + suite: "fixture.Suite", + case: "failsCase", + targetId: "test-target", + }), + ); + await act(async () => f.button("Allow and execute").click()); + const files = useFileSystemStore.getStore(f.workspaceId); + const original = files.getState().handleFileSelect; + const opened: unknown[][] = []; + files.setState({ + handleFileSelect: async (...args) => { + opened.push(args); + return undefined; + }, + }); + cleanups.push(() => files.setState({ handleFileSelect: original })); + await f.send({ + type: "diagnostics", + diagnostics: [ + { + path: "app/main.ts", + line: 4, + column: 9, + severity: "error", + message: "native fixture diagnostic", + }, + ], + }); + await f.send({ + type: "testResults", + results: { + tests: [ + { + suite: "fixture.Suite", + case: "failsCase", + status: "failed", + message: "expected fixture value", + path: "app/test.ts", + line: 5, + invocations: 1, + }, + ], + summary: { total: 1, passed: 0, failed: 1, errors: 0, skipped: 0 }, + success: false, + }, + }); + expect(f.host.textContent).toContain("native fixture diagnostic"); + expect(f.host.textContent).toContain("expected fixture value"); + await act(async () => + f.host.querySelector('[data-slot="build-output-problems"] button')!.click(), + ); + expect(opened).toEqual([ + [expect.stringMatching(/D:[\\/]fixture[\\/]run-ui[\\/]app[\\/]main\.ts/), false, 4, 9], + ]); + await act(async () => f.completion.resolve({ kind: "failed", exitCode: 1 })); +}); +test("only task execution model entries are selectable and rejection never executes", async () => { + const f = await mount(); + await f.choose("Action", "Model tasks"); + await f.choose("Project", "App (app)"); + await act(async () => + openSelect(f.host.querySelector('[aria-label="Model task"]')!), + ); + expect(document.body.textContent).toContain("Checks / Extended / Verify everything"); + expect(document.querySelector('[role="listbox"]')!.textContent).not.toContain( + "Background service", + ); + const model = [...document.querySelectorAll('[role="option"]')].find( + (row) => row.textContent === "Checks / Extended / Verify everything", + )!; + await act(async () => press(model)); + await f.prepare(); + expect(f.native.prepare).toHaveBeenCalledWith( + ROOT, + PLUGIN, + "fixture", + ".", + expect.objectContaining({ + modelTaskId: "model-task", + taskId: "from-model", + projectId: "project-app", + }), + ); + await act(async () => f.button("Decline").click()); + expect(f.native.execute).toHaveBeenCalledTimes(0); + expect(document.querySelector('[role="dialog"]')).toBeNull(); +}); +test("modelReloadRequired gives a localized recovery action rather than entering execute", async () => { + const f = await mount(view(), "zh-CN"); + f.native.prepare = mock(async () => { + throw new Error("plugin.build.modelReloadRequired"); + }); + await f.choose("操作", "测试"); + await f.prepare(); + expect(f.host.textContent).toContain("模型缺失或已过期"); + expect(f.host.textContent).toContain("加载 / 重新加载模型"); + expect(f.native.execute).toHaveBeenCalledTimes(0); +}); +test("closing and reopening the production window retains failed cancellation and retries the original operation", async () => { + const f = await mount(); + await f.choose("Action", "Lifecycle / Build fixture"); + await f.prepare(); + await act(async () => f.button("Allow and execute").click()); + f.native.cancel = mock(async () => { + throw new Error("fixture failed cleanup"); + }); + await act(async () => f.root.render(null)); + expect(f.unlisten).toHaveBeenCalledTimes(1); + await act(async () => f.render()); + expect(f.host.textContent).toContain("fixture failed cleanup"); + expect(f.button("Retry cancellation")).toBeDefined(); + f.native.cancel = mock(async () => {}); + await act(async () => f.button("Retry cancellation").click()); + expect(f.native.cancel).toHaveBeenCalledWith(f.review.operationId); + await act(async () => f.completion.resolve({ kind: "cancelled" })); + expect(f.host.textContent).toContain("Task / test operation ended"); +}); diff --git a/windows/tauri/src/features/build-systems/components/build-run-controls.tsx b/windows/tauri/src/features/build-systems/components/build-run-controls.tsx new file mode 100644 index 0000000000..b73744a37f --- /dev/null +++ b/windows/tauri/src/features/build-systems/components/build-run-controls.tsx @@ -0,0 +1,411 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { useTranslation } from "@/i18n/locale-provider"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { Button } from "@/ui/button"; +import Input from "@/ui/input"; +import Select from "@/ui/select"; +import Textarea from "@/ui/textarea"; +import AppDialog from "@/ui/dialog"; +import { Spinner } from "@/ui/spinner"; +import { buildRunNative, type BuildRunNative } from "../build-run-native"; +import { type BuildRunSession, getBuildRunSession } from "../build-run-session"; +import type { BuildInstanceView } from "../build-systems-types"; +import type { BuildRunDefinition, BuildRunScope, BuildRunSelection } from "../build-run-types"; +import BuildOutputPane from "../output/components/build-output-pane"; + +export default function BuildRunControls({ + instance, + disabled, + native = buildRunNative, +}: { + instance: BuildInstanceView; + disabled: boolean; + native?: BuildRunNative; +}) { + const { t } = useTranslation(); + const workspaceId = useActiveWorkspaceId(); + const [session, setSession] = useState(null); + const [acquireError, setAcquireError] = useState(""); + const [acquireRevision, setAcquireRevision] = useState(0); + useEffect(() => { + setAcquireError(""); + try { + const next = getBuildRunSession( + workspaceId, + instance.pluginID, + instance.buildSystemID, + instance.root, + native, + ); + const release = next.attach(); + setSession(next); + return release; + } catch (error) { + setSession(null); + setAcquireError(error instanceof Error ? error.message : String(error)); + } + }, [ + workspaceId, + instance.pluginID, + instance.buildSystemID, + instance.root, + native, + acquireRevision, + ]); + if (acquireError) + return ( +

    +

    + {t("buildSystems.run.failed", { message: acquireError })} +

    + +
    + ); + return session?.workspaceId === workspaceId && + session.pluginId === instance.pluginID && + session.buildSystemId === instance.buildSystemID && + session.root === instance.root ? ( + + ) : null; +} + +function RunContent({ + instance, + disabled, + session, +}: { + instance: BuildInstanceView; + disabled: boolean; + session: BuildRunSession; +}) { + const { t } = useTranslation(); + const state = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot); + const [definition, setDefinition] = useState(null); + const [definitionError, setDefinitionError] = useState(""); + const [revision, setRevision] = useState(0); + const [action, setAction] = useState(""); + const [scope, setScope] = useState("workspace"); + const [projectId, setProjectId] = useState(""); + const [targetId, setTargetId] = useState(""); + const [modelTaskId, setModelTaskId] = useState(""); + const [suite, setSuite] = useState(""); + const [testCase, setCase] = useState(""); + const [argumentsText, setArguments] = useState(""); + useEffect(() => { + let active = true; + setDefinition(null); + setDefinitionError(""); + void session.native.definition(instance.pluginID, instance.buildSystemID).then( + (value) => { + if (active) setDefinition(value); + }, + (error) => { + if (active) setDefinitionError(error instanceof Error ? error.message : String(error)); + }, + ); + return () => { + active = false; + }; + }, [session, instance.pluginID, instance.buildSystemID, revision]); + const tasks = (definition?.tasks ?? []).filter( + (row) => row.fromModel || !row.execution || row.execution === "task", + ); + const task = tasks.find((row) => `task:${row.id}` === action); + const testing = action === "test"; + const scopes = task?.fromModel + ? ["project" as const] + : testing + ? (definition?.test?.scopes ?? []) + : (task?.scopes ?? []); + const effectiveScope = scopes.includes(scope) ? scope : scopes[0]; + const project = instance.model?.projects?.find((row) => row.id === projectId); + const tags = testing ? (definition?.test?.targetTags ?? []) : (task?.targetTags ?? []); + const targets = (instance.model?.targets ?? []).filter( + (row) => + row.id && + row.projectID === project?.id && + (!tags.length || tags.some((tag) => row.tags?.includes(tag))), + ); + const target = targets.find((row) => row.id === targetId); + const modelTasks = (instance.model?.tasks ?? []).filter( + (row) => row.projectID === project?.id && row.template === task?.id && row.execution === "task", + ); + const modelTask = modelTasks.find((row) => row.id === modelTaskId); + const userArguments = argumentsText.split(/\r?\n/).filter((value) => value.length > 0); + const valid = + !!(task || testing) && + !!effectiveScope && + (effectiveScope === "workspace" || !!project?.id) && + (effectiveScope !== "target" || !!target?.id) && + (!task?.fromModel || !!modelTask) && + (!task?.requiresUserArguments || userArguments.length > 0); + const busy = ["preparing", "review", "starting", "running", "stopping", "cancelFailed"].includes( + state.phase, + ); + const run = () => { + if (!valid || busy || disabled) return; + const selection: BuildRunSelection = { + kind: testing ? "test" : "task", + taskId: task?.id ?? null, + projectId: effectiveScope === "workspace" ? null : project!.id!, + targetId: effectiveScope === "target" ? target!.id! : null, + modelTaskId: task?.fromModel ? modelTask!.id : null, + suite: testing && definition?.test?.filters?.includes("suite") ? suite || null : null, + case: testing && definition?.test?.filters?.includes("case") ? testCase || null : null, + userArguments, + }; + void session.start(selection); + }; + const failure = state.error ?? definitionError; + const reloadRequired = failure?.includes("plugin.build.modelReloadRequired"); + const missingModel = + effectiveScope && effectiveScope !== "workspace" && (!instance.model || instance.stale); + return ( +
    +

    {t("buildSystems.run.title")}

    + {definition && ( +
    + { + setScope(value as BuildRunScope); + setTargetId(""); + setModelTaskId(""); + }} + options={scopes.map((value) => ({ + value, + label: t(`buildSystems.run.scope.${value}`), + }))} + /> + )} + {effectiveScope && effectiveScope !== "workspace" && ( + ({ + value: row.id!, + label: `${row.name} [${(row.tags ?? []).join(", ")}]`, + })), + ]} + /> + )} + {task?.fromModel && ( + setSuite(event.target.value)} + /> + )} + {testing && definition.test?.filters?.includes("case") && ( + setCase(event.target.value)} + /> + )} + {(task || testing) && ( +