根插件名单(RPM)
根插件名单(Root Plugin Manifest,RPM)是构建脚本从源码生成、内嵌于产物、运行期不可变的清单。它回答两个问题:这个构建里有哪些大插件,以及每个大插件的代码身份是什么。
框架 = 构建期焊死的根骨架(RPM)+ 运行期可变的四类单元。
相关页面:架构总览、大插件与小插件、装载管线与 guard、装载原因码。
生成链路
src/core/<id>/contract.ts src/core/<id>/index.ts
│ 扫描并求值 │ 独立构建入口
▼ ▼
契约校验(id、名称、policy) core/<id>.js
│ │ SHA-256
└──────────────┬─────────────────┘
▼
rpmBody + manifestHash
│
┌──────────────┴──────────────┐
▼ ▼
dist/rpm.json boot 内嵌 MANIFEST_HASH- 构建插件扫描
src/core/下的目录,每个目录必须有contract.ts与index.ts;缺任一即构建失败。 contract.ts必须是纯字面量的defineContract声明。构建插件求值后核对id与目录名一致,并校验服务、插槽、registry 的命名与 policy;Kernel 是必需的根插件。- 每个根插件入口作为独立构建输入,产物固定命名为
core/<id>.js。入口保留apply、name、inject、contract等导出。 entryHash取该入口产物代码字节的 SHA-256。它是代码身份,不是版本号。manifestHash取「去掉manifestHash自身后的 RPM」经 JCS 规范化后的 SHA-256。- 构建时 boot 先以等长占位符编译,生成 RPM 后回填真实哈希;占位符缺失或根插件入口里出现该占位符都会导致构建失败,避免哈希循环。
- 构建产物同时写出
rpm.json与剧本 IR 的 JSON Schema;Tauri 构建再读取rpm.json与 boot 模块,生成 Rust 常量。 buildId形如YYYY.MM.DD-<git 短哈希>;工作区有未提交改动时追加-dirty,release 渠道遇到-dirty直接失败。- release 构建会扫描调试标记,命中即失败;缺少根公钥时按「发布未就绪」终止。
开发服务器被视为 debug 渠道产物:每次请求按服务器实际下发的入口模块文本重新计算 RPM,并在 /rpm.json 返回。根插件或内置包源码变化时,常量模块失效并整页重载,保证浏览器拿到的哈希与实际模块字节一致。
构建目标
| 目标 | 入口 | 根插件集合 |
|---|---|---|
| runtime | index.html | 不含 Author |
| editor | editor.html,输出到 dist-editor/ | 含 Author |
两个目标由同一份核心代码生成名单,差异只在根插件集合。Author 只进入 editor 目标,运行时不登记。
字段
| 字段 | 形状 | 作用 |
|---|---|---|
buildId | 字符串 | 框架版本的唯一载体;Tauri 握手核对 |
buildChannel | release / debug | 编译期注入,决定释放能力;不是运行期环境变量 |
rootPlugins[] | { id, contract, entryHash } | 根插件集合;契约是兼容承诺,入口哈希是代码身份 |
services[] | { owner, id, policy } | 对外服务及其 policy,供 intercept 与 isolate 域内 provide 的准入与越权审计 |
slots[] | { owner, id, policy, kind } | 实现位与机制插槽白名单;kind 取 registry 或 mechanism |
registries[] | { owner, id, policy } | 接受数据包写入的 registry;policy 是新建条目的缺省值,只取 closed / public |
builtinPacks[] | { id, archiveHash } | 随产物发布的内置包及其归档哈希 |
manifestHash | sha256:... | 整体指纹,覆盖上述除自身外的全部字段 |
契约版本是承诺,哈希是身份,两者都要。 只有版本无法发现入口被替换;只有哈希无法表达兼容窗口。
启动核验
启动时 src/boot/main.ts 按以下顺序执行,整个过程共享 60 秒期限;任一步不符或超时即拒绝启动,不加载任何根插件:
- 以
no-store读取rpm.json,做严格形状校验。字段集合必须完全匹配,出现不认识的字段同样按不合格处理。 - 核对
buildChannel与编译期常量一致。 - 核对
manifestHash既等于产物内嵌的MANIFEST_HASH,又等于重新计算的结果。 - 在
rootPlugins中找到kernel,读取 Kernel 入口字节并计算 SHA-256,与entryHash比对。 - 导入 Kernel,把其余根插件的入口字节读取器与加载器交给它;Kernel 用与普通单元相同的
judge判定根插件,核对契约与入口哈希。 - 根插件就绪后,按
builtinPacks装载内置包。内置包必须是 RPM 登记的 id、归档哈希一致,且只能是数据包或资源包;否则拒绝启动。 - 任一步失败都会撤回已建立的信任页通道、已挂载的根插件与 Kernel fiber;超时以
GUARD_INDETERMINATE拒绝,并按建立顺序逆序清理,停在拒绝态。
Kernel 入口不符对应 GUARD_ROOT_ENTRY_MISMATCH;清单或构建渠道不符对应 GUARD_RPM_HASH_MISMATCH;形状或参照缺失按 GUARD_INDETERMINATE 处理。完整列表见装载原因码。
Tauri 握手
Tauri 宿主不信任网页侧自报的身份。src-tauri/build.rs 在编译期读取 dist/rpm.json 与 dist/index.html 指向的 boot 模块,生成以下 Rust 常量:
| 常量 | 来源 |
|---|---|
BUILD_CHANNEL | rpm.json,并须与 Cargo 特性 debug-channel 一致,否则拒绝编译 |
BUILD_ID | rpm.json |
MANIFEST_HASH | rpm.json |
RPM_SHA256 | rpm.json 文件字节的 SHA-256 |
BOOT_PATH、BOOT_SHA256 | 入口 HTML 中 type="module" 的 boot 模块及其字节哈希 |
启动时 boot 经宿主的启动握手接口上报 buildId 与 manifestHash。宿主同时检查启动自检结果与这两个值;任一不符即把握手状态置为失败,并关闭全部原生路由。此后所有调用按未知命令处理,不回显具体差异。
这是宿主侧的真实边界:网页侧无法通过改写自己的常量取得原生路由。纯浏览器构建没有这条链路,能力由浏览器宿主提供。
热更新边界
热更新只能更新四类可变单元:小插件、数据包、资源包、Mod。不得更新大插件,不得更新 RPM。
| 变更 | 是否可热更新 | 正确路径 |
|---|---|---|
| 新增或替换一个实现位条目 | 可以 | 发布小插件 |
| 新增章节、规则或资源 | 可以 | 发布数据包或资源包 |
| 改写机制插槽或服务行为 | 可以 | 发布 Mod |
| 修改大插件内部逻辑或契约 | 不可以 | 新构建、重新生成 RPM、重新发布 |
修改 rpm.json、根插件入口或名单字段 | 不可以 | 新构建、重新发布 |
第三方不能自行新增大插件。新增整块功能的路径是 fork、添加模块、重新生成名单并发布新产物;代价是 fork 的 slots 与上游不再一致,其 Mod 生态兼容性随之下降。
RPM 守护的是守规单元:它们不能在允许的扩展点之外声明落点,声明层面的越权可机械判定。它不守护用户修改自己本地的产物,也不阻止不守规代码在任意位置植入代码;这是流程约束,不是技术隔离。
版本兼容窗口
策略是保证 N-1 兼容。
| 编号 | 规则 |
|---|---|
| V1 | 声明 ^N 契约的单元,可在框架 N 与 N+1 上运行;框架 N+2 起不再接受 ^N |
| V2 | guard 的契约判定接受范围是 [current-1, current] |
| V3 | 实现位只能新增,不得删除。弃用须保留一个完整版本周期:标记 deprecated 但仍可注册,仅在下个主版本移除 |
| V4 | 数据包与资源包必须声明 ir: ^N |
| V5 | 老编辑器不能打开新版本 IR 的数据包;必须给出明确错误,不得静默降级 |
实现位命名一旦交付或被任何已发布单元引用,就长期存在。唯一例外是尚未交付、且从未被任何已发布单元引用过的实现位,允许在主线内改名。第一版应把实现位设计得足够全,因为「删」比「加」贵得多。
契约文本的形状是 <大插件名>@<主版本>,例如 scriptplay@1。targets 中声明的每一项都必须能匹配 RPM 中的某个根插件契约,否则拒绝装载。
RPM 作为三份权威
| 用途 | 依据 |
|---|---|
| 准入门禁 | slots、services、rootPlugins |
| 审计参照 | slots 与 services,用于越权判定 |
| Mod 兼容判定 | rootPlugins[].contract |
三份用途都来自同一份构建产物。手写清单必然漂移,而漂移的清单比没有清单更危险,因此名单禁止手写。
常见问题
| 问题 | 回答 |
|---|---|
能否手工编辑 rpm.json 来加一个大插件? | 不能。启动核验会拒绝;Tauri 还会在编译期把哈希编进宿主常量 |
| 为什么入口哈希之外还要契约版本? | 哈希只证明「是这份代码」,契约版本才表达「能与哪些单元兼容」 |
| 为什么网页与 Tauri 用同一份名单? | 两端由同一份核心代码生成;宿主差异不改变根插件集合 |
| 更换实现需要重新生成 RPM 吗? | 不需要。实现位条目属于运行期可变单元;大插件结构不变,名单也不变 |
| RPM 能阻止恶意代码吗? | 不能。它只做装载期与声明层面的约束,不构成技术隔离,也不评估代码是否安全 |