From 2ed46695a20221f9e194e68739f1132bd30fa08f Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Fri, 9 Oct 2026 12:02:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20=E6=9D=A1=E7=9B=AE=E4=BB=A5=20?= =?UTF-8?q?sha256=20=E7=BB=91=E5=AE=9A=E5=AE=A1=E6=9F=A5=E8=BF=87=E7=9A=84?= =?UTF-8?q?=20zip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 检查打出的 zip 的 sha256 写进条目。合并后在独立 job 中重新打包的 zip、 签名 job 汇总的 zip(含复用的旧 zip)都必须与它一致才签名,构建在 PR 与 合并后产出不同字节时发布失败,不会签下未审查的内容。条目缺少或写错 sha256 时,PR 检查在摘要中给出应填的值。 --- .github/PULL_REQUEST_TEMPLATE/plugin.md | 1 + backend/src/test/test_plugin_registry.py | 92 ++++++++++++++++++------ docs/dev/plugins/publish.md | 6 +- docs/en/dev/plugins/publish.md | 6 +- docs/ja/dev/plugins/publish.md | 6 +- plugins/registry/custom-rss-site.toml | 1 + plugins/registry/manual-pick.toml | 1 + plugins/registry/nfo-writer.toml | 1 + plugins/registry/ntfy-notifier.toml | 1 + plugins/registry/template-rename.toml | 1 + plugins/registry/webhook-on-event.toml | 1 + scripts/plugin_registry.py | 38 ++++++++-- 12 files changed, 120 insertions(+), 35 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE/plugin.md b/.github/PULL_REQUEST_TEMPLATE/plugin.md index f05ce401..cc9784d1 100644 --- a/.github/PULL_REQUEST_TEMPLATE/plugin.md +++ b/.github/PULL_REQUEST_TEMPLATE/plugin.md @@ -11,6 +11,7 @@ - [ ] This PR changes only files under `plugins/registry/`. - [ ] `commit` is a full 40-character SHA, and it is pushed to a public repository. +- [ ] `sha256` matches the value in the CI summary (leave it out in the first push; CI shows it). - [ ] The file name equals the `id` in `plugin.toml`. - [ ] `tests/` has at least 1 test, and all tests pass. - [ ] `ab-plugin validate` passes. diff --git a/backend/src/test/test_plugin_registry.py b/backend/src/test/test_plugin_registry.py index 06de7689..576c5989 100644 --- a/backend/src/test/test_plugin_registry.py +++ b/backend/src/test/test_plugin_registry.py @@ -4,7 +4,9 @@ import base64 import hashlib import importlib.util import json +import zipfile from pathlib import Path +from types import SimpleNamespace import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey @@ -48,6 +50,7 @@ class TestEntryParse: (PLUGIN_ID, entry_text(commit="abc1234"), "40 位"), (PLUGIN_ID, entry_text(repo="https://github.com/a/b"), "owner/name"), (PLUGIN_ID, entry_text(path="../other"), "相对路径"), + (PLUGIN_ID, entry_text(sha256="ABC"), "64 位"), (PLUGIN_ID, entry_text(tag="v1"), "未知字段"), (PLUGIN_ID, 'repo = "alice/ab-demo"\n', "缺少字段"), ("Bad_Id", entry_text(), "不是合法的插件 id"), @@ -134,12 +137,39 @@ def test_summary_escapes_author_output(reg): assert "|" in text +@pytest.mark.parametrize("given", ["", "e" * 64]) +def test_check_tells_author_the_reviewed_sha256(reg, tmp_path, monkeypatch, given): + src = scaffold(tmp_path / "src") + text = entry_text(sha256=given) if given else entry_text() + monkeypatch.setattr( + reg, + "_git", + lambda *a: SimpleNamespace( + returncode=0 if a[0] == "show" and "head" in a[1] else 1, + stdout=text if a[0] == "show" else "", + ), + ) + monkeypatch.setattr(reg, "fetch", lambda entry, workdir: src) + + result = reg.check_entry(PLUGIN_ID, "base", "head", {}, tmp_path / "out", tmp_path) + + expected = hashlib.sha256(next((tmp_path / "out").glob("*.zip")).read_bytes()) + assert not result.ok + assert result.lines[-1] == ( + "拒绝", + f'在条目中写入 sha256 = "{expected.hexdigest()}"(当前为 {given or "空"})', + ) + + def test_unexpected_error_is_reported_per_entry(reg, tmp_path, monkeypatch): monkeypatch.setattr(reg, "_git", lambda *a: (_ for _ in ()).throw(OSError("boom"))) result = reg.check_entry(PLUGIN_ID, "base", "head", {}, tmp_path, tmp_path) assert not result.ok and result.lines == [("错误", "OSError: boom")] +DIGEST = "d" * 64 + + def published_catalog(**overrides): prev = { "id": PLUGIN_ID, @@ -147,7 +177,7 @@ def published_catalog(**overrides): "repo": "alice/ab-demo", "commit": SHA, "asset": f"{PLUGIN_ID}-0.1.0.zip", - "sha256": "0" * 64, + "sha256": DIGEST, } return {PLUGIN_ID: prev | overrides} @@ -157,9 +187,25 @@ class TestPublish: def registry(self, reg, tmp_path, monkeypatch): root = tmp_path / "registry" root.mkdir() - (root / f"{PLUGIN_ID}.toml").write_text(entry_text()) monkeypatch.setattr(reg, "REGISTRY", root) - return root + + def write(sha256: str = DIGEST) -> None: + (root / f"{PLUGIN_ID}.toml").write_text(entry_text(sha256=sha256)) + + write() + return write + + @pytest.fixture + def built(self, reg, registry, tmp_path, monkeypatch): + """脚手架插件经 build-one 打包;条目写入它审查过的 sha256。""" + src = scaffold(tmp_path / "src") + (src / "README.md").write_text("# demo\n") + monkeypatch.setattr(reg, "fetch", lambda entry, workdir: src) + data = pack(src, tmp_path / "probe", load_manifest(src)).read_bytes() + registry(hashlib.sha256(data).hexdigest()) + out = tmp_path / "dist" / f"plugin-{PLUGIN_ID}" + reg.build_one(PLUGIN_ID, out) + return out, data @pytest.mark.parametrize( ("published", "rebuild", "expected"), @@ -169,6 +215,7 @@ class TestPublish: (published_catalog(), True, "build"), (published_catalog(commit="b" * 40), False, "build"), (published_catalog(path="other"), False, "build"), + (published_catalog(sha256="e" * 64), False, "build"), ], ) def test_plan_reuses_only_unchanged_sources( @@ -177,8 +224,8 @@ class TestPublish: assert reg.plan(published, rebuild)[expected] == [PLUGIN_ID] def test_collect_reuses_released_zip(self, reg, registry, tmp_path, monkeypatch): - src = scaffold(tmp_path / "src") - data = pack(src, tmp_path / "old", load_manifest(src)).read_bytes() + data = b"released zip" + registry(hashlib.sha256(data).hexdigest()) published = published_catalog( sha256=hashlib.sha256(data).hexdigest(), readme="hi" ) @@ -199,15 +246,10 @@ class TestPublish: published_catalog(), False, tmp_path / "dist", tmp_path / "stage" ) - def test_build_one_then_collect(self, reg, registry, tmp_path, monkeypatch): - src = scaffold(tmp_path / "src") - (src / "README.md").write_text("# demo\n") - monkeypatch.setattr(reg, "fetch", lambda entry, workdir: src) - reg.build_one(PLUGIN_ID, tmp_path / "dist" / f"plugin-{PLUGIN_ID}") - + def test_build_one_then_collect(self, reg, built, tmp_path): reg.collect({}, False, tmp_path / "dist", tmp_path / "stage") - assert (tmp_path / "stage" / f"{PLUGIN_ID}-0.1.0.zip").is_file() + assert (tmp_path / "stage" / f"{PLUGIN_ID}-0.1.0.zip").read_bytes() == built[1] sources = json.loads((tmp_path / "stage" / "sources.json").read_text()) assert sources[PLUGIN_ID] == { "repo": "alice/ab-demo", @@ -216,21 +258,27 @@ class TestPublish: "readme": "# demo\n", } - @pytest.mark.parametrize("tamper", ["extra_zip", "wrong_source"]) - def test_collect_rejects_tampered_build_output( - self, reg, registry, tmp_path, monkeypatch, tamper + def test_build_one_refuses_bytes_that_differ_from_review( + self, reg, built, registry, tmp_path ): - src = scaffold(tmp_path / "src") - monkeypatch.setattr(reg, "fetch", lambda entry, workdir: src) - built = tmp_path / "dist" / f"plugin-{PLUGIN_ID}" - reg.build_one(PLUGIN_ID, built) + registry("e" * 64) + with pytest.raises(reg.Reject, match="不一致"): + reg.build_one(PLUGIN_ID, tmp_path / "again") + + @pytest.mark.parametrize("tamper", ["extra_zip", "wrong_source", "other_bytes"]) + def test_collect_rejects_tampered_build_output(self, reg, built, tmp_path, tamper): + out, _ = built if tamper == "extra_zip": other = scaffold(tmp_path / "other", "other-plugin") - pack(other, built, load_manifest(other)) - else: - (built / "source.json").write_text( + pack(other, out, load_manifest(other)) + elif tamper == "wrong_source": + (out / "source.json").write_text( json.dumps({"repo": "mallory/x", "commit": SHA, "path": "."}) ) + else: + zip_path = next(out.glob("*.zip")) + with zipfile.ZipFile(zip_path, "a") as zf: + zf.writestr("evil.py", "import os\n") with pytest.raises(SystemExit): reg.collect({}, False, tmp_path / "dist", tmp_path / "stage") diff --git a/docs/dev/plugins/publish.md b/docs/dev/plugins/publish.md index d800bd4b..2cfb7f57 100644 --- a/docs/dev/plugins/publish.md +++ b/docs/dev/plugins/publish.md @@ -10,17 +10,19 @@ AutoBangumi 4.0.0-beta.2 及之后的版本信任插件密钥。4.0.0-beta.1 无 ## 登记文件 -文件 `plugins/registry/.toml` 只有三个字段: +文件 `plugins/registry/.toml` 有四个字段: ```toml repo = "owner/name" # 公开的 GitHub 仓库 commit = "<40 位完整 SHA>" # tag 可以移动,SHA 不能 path = "." # 可选。插件所在子目录,默认是仓库根 +sha256 = "<64 位>" # PR 检查打出的 zip 的 sha256;先留空,CI 会给出应填的值 ``` - 文件名必须等于 `plugin.toml` 里的 `id`。 - `commit` 必须是 40 位完整 SHA。短 SHA 和 tag 会被拒绝。 - 名称、版本、`sdk` 范围、作者、权限都从该 commit 的 `plugin.toml` 读取,不在登记文件里重复写。 +- `sha256` 绑定审查过的字节:合并后重新打包的 zip 必须与它完全相同,CI 才会签名。第一次提交 PR 时可以不写,CI 会在摘要里给出应填的值,补上后再推送一次。 一个 PR 可以改多个登记文件。建议一个 PR 只含一个插件。 @@ -80,7 +82,7 @@ path = "." # 可选。插件所在子目录,默认是仓库根 1. 在自己的仓库里改代码,并提高 `plugin.toml` 里的 `version`。 2. 推送,用 `git rev-parse HEAD` 取得新的完整 SHA。 -3. 向 `4.0-dev` 提交 PR,只把 `plugins/registry/.toml` 里的 `commit` 改成新 SHA。 +3. 向 `4.0-dev` 提交 PR,把 `plugins/registry/.toml` 里的 `commit` 改成新 SHA,再按 CI 摘要更新 `sha256`。 版本必须高于目录中已有的版本,否则 CI 拒绝。 diff --git a/docs/en/dev/plugins/publish.md b/docs/en/dev/plugins/publish.md index ea3c7e92..6d477495 100644 --- a/docs/en/dev/plugins/publish.md +++ b/docs/en/dev/plugins/publish.md @@ -10,17 +10,19 @@ AutoBangumi 4.0.0-beta.2 and later trust the plugin key. 4.0.0-beta.1 cannot ins ## The registry entry -The file `plugins/registry/.toml` has three fields: +The file `plugins/registry/.toml` has four fields: ```toml repo = "owner/name" # public GitHub repository commit = "<40-char full SHA>" # tags can move, a SHA cannot path = "." # optional subdirectory of the plugin, default is the repo root +sha256 = "<64 hex>" # sha256 of the zip the PR check builds; leave it out first, CI tells you the value ``` - The file name must equal the `id` in `plugin.toml`. - `commit` must be a full 40-character SHA. A short SHA or a tag is rejected. - The name, version, `sdk` range, authors and permissions come from `plugin.toml` at that commit. Do not repeat them in the entry. +- `sha256` binds the signature to the reviewed bytes: after merge, CI signs the rebuilt zip only if it is identical. You can leave it out in the first push. CI shows the value in the summary. Add it and push again. A PR can change several entries. We recommend one plugin per PR. @@ -80,7 +82,7 @@ A user sees the new plugin or version through `GET /api/v1/plugins/catalog`. For 1. Change the code in your repository and raise `version` in `plugin.toml`. 2. Push, then get the new full SHA with `git rev-parse HEAD`. -3. Open a PR to `4.0-dev`. In `plugins/registry/.toml`, change only `commit` to the new SHA. +3. Open a PR to `4.0-dev`. In `plugins/registry/.toml`, change `commit` to the new SHA, then set `sha256` to the value from the CI summary. The version must be higher than the listed version. If not, CI rejects the PR. diff --git a/docs/ja/dev/plugins/publish.md b/docs/ja/dev/plugins/publish.md index 24dcf402..ddc9b4c6 100644 --- a/docs/ja/dev/plugins/publish.md +++ b/docs/ja/dev/plugins/publish.md @@ -10,17 +10,19 @@ AutoBangumi 4.0.0-beta.2 以降はプラグイン用の鍵を信頼します。4 ## 登録ファイル -ファイル `plugins/registry/.toml` のフィールドは 3 つです。 +ファイル `plugins/registry/.toml` のフィールドは 4 つです。 ```toml repo = "owner/name" # 公開 GitHub リポジトリ commit = "<40 文字の完全な SHA>" # タグは動かせるが、SHA は動かせない path = "." # 任意。プラグインのサブディレクトリ。既定はリポジトリのルート +sha256 = "<64 桁>" # PR チェックで作られる zip の sha256。最初は省略し、CI が示す値を書きます ``` - ファイル名は `plugin.toml` の `id` と一致させます。 - `commit` は 40 文字の完全な SHA にします。短い SHA やタグは拒否されます。 - 名前、バージョン、`sdk` の範囲、作者、権限は、その commit の `plugin.toml` から読み取ります。登録ファイルに重複して書かないでください。 +- `sha256` は署名をレビュー済みのバイトに結び付けます。マージ後に作り直した zip がこれと完全に一致するときだけ、CI は署名します。最初の push では省略できます。CI がサマリーに値を示すので、追記して再度 push します。 1 つの PR で複数のエントリを変更できます。1 PR に 1 プラグインを推奨します。 @@ -80,7 +82,7 @@ path = "." # 任意。プラグインのサブディレクトリ。既定は 1. 自分のリポジトリでコードを変更し、`plugin.toml` の `version` を上げます。 2. push して、`git rev-parse HEAD` で新しい完全な SHA を取得します。 -3. `4.0-dev` に PR を作ります。`plugins/registry/.toml` では `commit` だけを新しい SHA に変更します。 +3. `4.0-dev` に PR を作ります。`plugins/registry/.toml` の `commit` を新しい SHA に変更し、CI サマリーの値で `sha256` を更新します。 バージョンはカタログにあるバージョンより高くする必要があります。そうでない場合、CI は PR を拒否します。 diff --git a/plugins/registry/custom-rss-site.toml b/plugins/registry/custom-rss-site.toml index 00bdfcd6..fa897086 100644 --- a/plugins/registry/custom-rss-site.toml +++ b/plugins/registry/custom-rss-site.toml @@ -1,3 +1,4 @@ repo = "EstrellaXD/Auto_Bangumi" commit = "f3359b17fde62ebfe32179071e7ae579431fd0cd" path = "examples/plugins/custom-rss-site" +sha256 = "a0af05e45f7837f81b744354cec569cfbdaf4a8fd6fd8beb7fdcbd9b22d23e6b" diff --git a/plugins/registry/manual-pick.toml b/plugins/registry/manual-pick.toml index 7d1dae86..51ef7b3e 100644 --- a/plugins/registry/manual-pick.toml +++ b/plugins/registry/manual-pick.toml @@ -1,3 +1,4 @@ repo = "EstrellaXD/Auto_Bangumi" commit = "f3359b17fde62ebfe32179071e7ae579431fd0cd" path = "examples/plugins/manual-pick" +sha256 = "08caa4a4693595b25fdf87a41fe16ee71b93a6595f4c0113e452f4d70a755aee" diff --git a/plugins/registry/nfo-writer.toml b/plugins/registry/nfo-writer.toml index bed1676c..031bc081 100644 --- a/plugins/registry/nfo-writer.toml +++ b/plugins/registry/nfo-writer.toml @@ -1,3 +1,4 @@ repo = "EstrellaXD/Auto_Bangumi" commit = "f3359b17fde62ebfe32179071e7ae579431fd0cd" path = "examples/plugins/nfo-writer" +sha256 = "f855e66c06b15ef713dabb0e6db3e9749e5adc1f6403d587d48a916071a64c22" diff --git a/plugins/registry/ntfy-notifier.toml b/plugins/registry/ntfy-notifier.toml index 8247260b..82deba90 100644 --- a/plugins/registry/ntfy-notifier.toml +++ b/plugins/registry/ntfy-notifier.toml @@ -1,3 +1,4 @@ repo = "EstrellaXD/Auto_Bangumi" commit = "f3359b17fde62ebfe32179071e7ae579431fd0cd" path = "examples/plugins/ntfy-notifier" +sha256 = "7315f86b034bc8fcb9e69527710a8d6f3551fc182560eac3d8245f1018d12ca6" diff --git a/plugins/registry/template-rename.toml b/plugins/registry/template-rename.toml index 9317b3aa..9d7b4e77 100644 --- a/plugins/registry/template-rename.toml +++ b/plugins/registry/template-rename.toml @@ -1,3 +1,4 @@ repo = "EstrellaXD/Auto_Bangumi" commit = "f3359b17fde62ebfe32179071e7ae579431fd0cd" path = "examples/plugins/template-rename" +sha256 = "4365af47c905641e85834d298aaf076d5a64aa3304aec6a57b7c3f7f10a62a3d" diff --git a/plugins/registry/webhook-on-event.toml b/plugins/registry/webhook-on-event.toml index 700ffb21..3caf0d89 100644 --- a/plugins/registry/webhook-on-event.toml +++ b/plugins/registry/webhook-on-event.toml @@ -1,3 +1,4 @@ repo = "EstrellaXD/Auto_Bangumi" commit = "f3359b17fde62ebfe32179071e7ae579431fd0cd" path = "examples/plugins/webhook-on-event" +sha256 = "e9acde2f67984ecbc84beea78011007d077be45b7cb9fb458bf31e35fecbca3a" diff --git a/scripts/plugin_registry.py b/scripts/plugin_registry.py index 1778257b..2044d90f 100644 --- a/scripts/plugin_registry.py +++ b/scripts/plugin_registry.py @@ -1,10 +1,14 @@ """插件市场的索引工具:``plugins/registry/.toml`` → 校验、测试、打包、汇总。 -条目只有三个字段,代码留在作者自己的仓库,用完整 SHA 固定(tag 可以移动):: +代码留在作者自己的仓库,用完整 SHA 固定(tag 可以移动):: repo = "owner/name" commit = "<40 位 SHA>" path = "." # 可选,插件在仓库中的子目录 + sha256 = "<64 位>" # PR 检查打出的 zip;签名只认这份审查过的字节 + +``sha256`` 留空时 PR 检查会给出应填的值。合并后重新打包的 zip 必须与它一致才会签名, +所以构建在 PR 和合并后产出不同字节时发布会失败,而不是签下没审过的内容。 在仓库根目录运行(backend 的虚拟环境里有 ab_sdk):: @@ -53,7 +57,8 @@ RELEASE_BASE = "https://github.com/EstrellaXD/Auto_Bangumi/releases/download/plu SHA_RE = re.compile(r"^[0-9a-f]{40}$") REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") README_LIMIT = 16 * 1024 -_FIELDS = {"repo", "commit", "path"} +DIGEST_RE = re.compile(r"^[0-9a-f]{64}$") +_FIELDS = {"repo", "commit", "path", "sha256"} class Reject(Exception): @@ -66,6 +71,7 @@ class Entry: repo: str commit: str path: str = "." + sha256: str = "" @classmethod def parse(cls, plugin_id: str, text: str) -> "Entry": @@ -84,6 +90,7 @@ class Entry: str(data["repo"]), str(data["commit"]), str(data.get("path", ".")), + str(data.get("sha256", "")), ) if not REPO_RE.match(entry.repo): raise Reject("repo 必须是 GitHub 的 owner/name") @@ -92,6 +99,8 @@ class Entry: path = PurePosixPath(entry.path) if path.is_absolute() or ".." in path.parts: raise Reject("path 必须是仓库内的相对路径") + if entry.sha256 and not DIGEST_RE.match(entry.sha256): + raise Reject("sha256 必须是 64 位小写十六进制") return entry @property @@ -316,6 +325,10 @@ def check_entry( archive = pack(src, out, manifest) digest = hashlib.sha256(archive.read_bytes()).hexdigest() lines.append(("zip", f"{archive.name} · sha256 {digest}")) + if entry.sha256 != digest: + raise Reject( + f'在条目中写入 sha256 = "{digest}"(当前为 {entry.sha256 or "空"})' + ) return Result(plugin_id, True, lines) except Reject as e: lines.append(("拒绝", str(e))) @@ -382,14 +395,14 @@ def cmd_check(base: str, head: str, catalog: Path | None, out: Path) -> int: def plan(published: dict[str, dict], rebuild: bool) -> dict[str, list[str]]: - """来源(repo、commit、path)未变的条目复用已发布的 zip,其余重新打包。""" + """来源(repo、commit、path)与 sha256 都未变的条目复用已发布的 zip,其余重新打包。""" build: list[str] = [] reuse: list[str] = [] for entry in registry_entries(): prev = published.get(entry.id, {}) same = { k: prev.get(k, "." if k == "path" else None) for k in entry.source - } == entry.source + } == entry.source and prev.get("sha256") == entry.sha256 (reuse if same and not rebuild else build).append(entry.id) return {"build": build, "reuse": reuse} @@ -402,8 +415,13 @@ def build_one(plugin_id: str, out: Path) -> None: src = fetch(entry, Path(tmp)) build_web(src) manifest = verify(entry, src, None, None) - pack(src, out, manifest) + archive = pack(src, out, manifest) source = {**entry.source, "readme": readme(src)} + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + if digest != entry.sha256: + raise Reject( + f"打出的 zip sha256 {digest} 与条目中审查过的 {entry.sha256} 不一致" + ) (out / "source.json").write_text( json.dumps(source, ensure_ascii=False), encoding="utf-8" ) @@ -432,8 +450,8 @@ def collect(published: dict[str, dict], rebuild: bool, dist: Path, stage: Path) for plugin_id in todo["reuse"]: prev = published[plugin_id] data = _download(f"{RELEASE_BASE}/{prev['asset']}") - if hashlib.sha256(data).hexdigest() != prev["sha256"]: - raise SystemExit(f"{prev['asset']} 与已发布 catalog 的 sha256 不一致") + if hashlib.sha256(data).hexdigest() != entries[plugin_id].sha256: + raise SystemExit(f"{prev['asset']} 与条目中的 sha256 不一致") (stage / prev["asset"]).write_bytes(data) sources[plugin_id] = { **entries[plugin_id].source, @@ -444,6 +462,12 @@ def collect(published: dict[str, dict], rebuild: bool, dist: Path, stage: Path) zips = sorted(built.glob("*.zip")) if len(zips) != 1 or _zip_id(zips[0]) != plugin_id: raise SystemExit(f"{plugin_id} 的构建产物不是恰好一个 id 相符的 zip") + # 构建 job 运行过作者代码:只认条目里审查过的 sha256 + if ( + hashlib.sha256(zips[0].read_bytes()).hexdigest() + != entries[plugin_id].sha256 + ): + raise SystemExit(f"{plugin_id} 的构建产物与条目中的 sha256 不一致") source = json.loads((built / "source.json").read_text("utf-8")) if {k: source.get(k) for k in entries[plugin_id].source} != entries[ plugin_id