From 13b0bd5b458b7e05f0468341b57b1983f12aabca Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Mon, 14 Sep 2026 06:39:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(release):=E5=AE=8C=E5=96=84=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E5=88=86=E5=8F=91=E8=AF=81=E6=98=8E=E4=B8=8E=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/bat.yml | 14 +- CURRENT_STATUS.md | 12 +- Makefile | 21 +- README.md | 3 +- TODO.md | 33 +- api/openapi/bat-api.yaml | 4 +- cmd/bat/ffi.go | 4 - cmd/bat/sync.go | 4 +- .../architecture/official-resource-backend.md | 8 +- docs/architecture/resource-release-layout.md | 9 +- docs/guides/baseline.md | 2 +- docs/guides/deployment.md | 2 +- docs/guides/development.md | 8 +- docs/reference/rpc-backend-api.md | 20 +- docs/reports/CURRENT_GAPS.md | 8 +- docs/reports/GO_STATUS.md | 8 +- infrastructure/src/bin/bat/app.rs | 156 +++++++-- infrastructure/src/bin/bat/app_tests.rs | 21 ++ infrastructure/src/bin/bat/task_registry.rs | 8 + infrastructure/src/lib.rs | 33 +- infrastructure/src/official_download.rs | 154 +++++++++ infrastructure/src/official_update.rs | 84 ++++- infrastructure/src/release_ops.rs | 311 +++++++++++++++++- internal/api/admin.go | 2 +- internal/api/api_test.go | 124 ++++++- internal/api/config.go | 3 +- internal/api/contract_fixture_test.go | 21 +- internal/api/openapi.go | 4 +- internal/api/release_attestation_test.go | 241 ++++++++++++++ internal/api/release_index.go | 34 +- internal/api/rpc_release.go | 222 +++++++++---- internal/api/testdata/contract/README.md | 4 +- .../contract/release-attestation.json | 18 + .../contract/resource-manifest.page0.json | 6 + internal/backendrpc/client.go | 61 +++- internal/backendrpc/client_test.go | 95 ++++++ scripts/check-doc-status.sh | 12 +- scripts/ci-check.sh | 35 +- scripts/ci-versions.sh | 9 + 39 files changed, 1590 insertions(+), 228 deletions(-) create mode 100644 internal/api/release_attestation_test.go create mode 100644 internal/api/testdata/contract/release-attestation.json create mode 100644 scripts/ci-versions.sh diff --git a/.gitea/workflows/bat.yml b/.gitea/workflows/bat.yml index bd3cc7a..6d19911 100644 --- a/.gitea/workflows/bat.yml +++ b/.gitea/workflows/bat.yml @@ -249,18 +249,18 @@ jobs: source /var/lib/act_runner/env.sh go build -o /tmp/bat-api ./cmd/bat-api - - name: Optional Go lint + - name: Required Go lint shell: bash run: | set -euo pipefail source /var/lib/act_runner/env.sh - if command -v golangci-lint >/dev/null 2>&1; then - export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" - golangci-lint run ./... - else - printf 'SKIP optional: Go lint (golangci-lint); reason=command not installed\n' - fi + source scripts/ci-versions.sh + command -v golangci-lint >/dev/null 2>&1 + actual="$(golangci_lint_actual_version)" + test "${actual}" = "${GOLANGCI_LINT_VERSION}" + export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" + golangci-lint run ./... - name: Run documentation status gate shell: bash diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index c234e27..f823cad 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -49,8 +49,10 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人 `localized.status` 现将 generic manifest schema/contract 与已发布 artifact integrity 分开报告;current、state 和 identity 存在但文件被截断或手工修改时返回 `localized.degraded`,只读检查不会自动回滚、删除或修复。双 release 的 -`release.status/list/distribution/cleanup` 已由 Rust 从既有状态、manifest、文件系统和 -CAS/reference 元数据统一生成,Go 仅 typed 转发。CAS repository 的对象文件、引用计数 +`release.attestation` 已由 Rust 从 current、publication anchor、manifest identity 和 +verification freshness 生成,供 Go current readiness 使用;`release.status/list/distribution/cleanup` +仍由 Rust 从既有状态、manifest、文件系统和 CAS/reference 元数据统一生成,Go 仅 typed 转发。 +CAS repository 的对象文件、引用计数 和 GC 通过跨进程操作锁协调,release-local CAS 引用以 `(ownership_id, ordinal)` ownership 记录幂等释放;新清单持久化 `ownership_id`,旧清单按 output-root scope、稳定 source identity 和 generation-aware legacy cleanup path 迁移;localized publish/rollback 通过 @@ -263,8 +265,8 @@ marker 存在时才保持兼容 key,无法证明归属时拒绝 cleanup;完 | Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 | 默认 Go/docs 只读门禁:`make ci-check`(Rust fmt/check/build/clippy/test、Go API -format/test/vet/build、docs/OpenAPI/RPC contract;无 FFI)。`make format` / `make fmt` -才会修改源码;`golangci-lint` 缺失时作为 optional gate 明确输出 skipped。 +format/test/vet/build、固定版本 `golangci-lint 2.12.2`、docs/OpenAPI/RPC contract; +无 FFI)。`make format` / `make fmt` 才会修改源码;required 工具缺失或版本不匹配直接失败。 --- @@ -308,7 +310,7 @@ cargo run -p bat-infrastructure --bin bat -- \ --watch ``` -资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root` 和 Rust `release.status` 的 whole-release `official_distribution_ready`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest`、`official-sync-snapshot.json` 和 Glossary query contract fixture,Go mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.*`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。 +资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取 Rust 当前 official `release.attestation`、再按 release/publication/manifest identity 绑定读取 `resource.manifest`,不在配置里写死资源目录;轻量 attestation 只读取当前 release、publication anchor、manifest identity 和 freshness,不遍历历史 release 或计算资源文件 BLAKE3。HTTP readiness 还要求 Go 分页快照完整且本地路径安全;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest`、`official-sync-snapshot.json` 和 Glossary query contract fixture,Go mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.*`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。 生产要求: diff --git a/Makefile b/Makefile index 21e66cb..35ffe3b 100644 --- a/Makefile +++ b/Makefile @@ -118,14 +118,19 @@ lint-rust: ## Rust Clippy 检查 @echo "$(BLUE)Running Clippy...$(NC)" cargo clippy --workspace --all-targets -- -D warnings -lint-go: ## Go Linter 检查 +lint-go: ## Go Linter 检查(required) @echo "$(BLUE)Running golangci-lint...$(NC)" - @command -v golangci-lint >/dev/null 2>&1 || { echo "$(YELLOW)OPTIONAL gate skipped: golangci-lint is not installed$(NC)"; exit 0; } - @if [ -n "$$(go list ./... 2>/dev/null)" ]; then \ - XDG_CACHE_HOME="$${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" golangci-lint run ./...; \ - else \ - echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \ - fi + @. scripts/ci-versions.sh; \ + command -v golangci-lint >/dev/null 2>&1 || { \ + echo "$(YELLOW)required gate failed: golangci-lint $${GOLANGCI_LINT_VERSION} is not installed$(NC)"; \ + exit 1; \ + }; \ + actual="$$(golangci_lint_actual_version)"; \ + test "$${actual}" = "$${GOLANGCI_LINT_VERSION}" || { \ + echo "$(YELLOW)required gate failed: golangci-lint version required=$${GOLANGCI_LINT_VERSION} actual=$${actual:-unknown}$(NC)"; \ + exit 1; \ + }; \ + XDG_CACHE_HOME="$${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" golangci-lint run ./... # ============================================================================ # 清理 @@ -191,7 +196,7 @@ docs: ## 生成文档 # CI/CD # ============================================================================ -ci-check: ## 运行只读 required CI 门禁(可选 lint 会明确标记) +ci-check: ## 运行只读 required CI 门禁(含固定版本 Go lint) @bash scripts/ci-check.sh ci: ci-check ## 运行只读 CI 检查(兼容旧命令名) diff --git a/README.md b/README.md index 757859f..0d1564a 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ make ci-check ``` `make ci-check` 是只读 required 门禁;`make format` / `make fmt` 才会格式化源码。 -如果环境中没有 `golangci-lint`,脚本会明确报告 optional gate skipped。 +Go lint 是 required gate,使用 `scripts/ci-versions.sh` 固定的 +`golangci-lint 2.12.2`;工具缺失或版本不匹配都会失败。 查看官方同步命令: diff --git a/TODO.md b/TODO.md index 8bb2c31..65d774e 100644 --- a/TODO.md +++ b/TODO.md @@ -62,7 +62,8 @@ * manifest entry 是否存在; * 文件 size 是否匹配。 -虽然 Rust 已有完整的发布状态、BLAKE3 校验与 release integrity 语义,但 Go 当前并未完整继承这一健康状态。 +虽然 Rust 已有完整的发布状态、BLAKE3 校验与 release integrity 语义,但 Go 当前需要继承 +一个轻量、带代际绑定和 freshness 的 current official health proof,而不是触发重型历史扫描。 因此可能出现: @@ -76,9 +77,9 @@ 建立统一的: ```text -Rust release health +Rust current release attestation ↓ -Go ReleaseIndex +Go ReleaseIndex(保留 release/publication/manifest identity) ↓ readiness / bootstrap ↓ @@ -93,6 +94,13 @@ CDN distribution gate * bootstrap 不应报告 distributable; * CDN 不应继续从该 release 分发任意文件。 +`release.attestation` 必须由 Rust 维护 publication anchor、manifest identity、 +verification generation、verified_at、integrity/status code 和 diagnostics;轻量读取 +只检查 current、anchor、manifest 元数据和 freshness,不遍历历史 release,不计算资源 +文件 BLAKE3。`resource.manifest` 请求绑定 attested `release_id`、 +`publication_identity`、`manifest_identity`,每页返回相同代际信息;Go 逐页验证 +release/root/identity/version/total/offset/limit,任何混页都丢弃候选快照。 + 保持现有: * GET @@ -143,10 +151,12 @@ B 同大小内容损坏,Rust health=false ## 完成记录 -Rust `release.status.official_distribution_ready` 已接入 Go refresh snapshot; -`readyz`、bootstrap、release summary 和普通 current CDN 共用 whole-release -distribution gate。刷新失败会清空旧 index,恢复后重新建立快照;显式 -official/localized/historical 分发仍经 Rust `release.distribution`。 +当前已完成 Rust current attestation、manifest 代际绑定、Go typed mirror、分页一致性和 +A→B 切换 fail-closed 测试;`readyz`、bootstrap、release summary 和普通 current CDN +共用 `attestation ready + exact manifest generation + complete local snapshot` gate。 +刷新失败会清空旧 index,恢复后重新建立快照;显式 official/localized/historical 分发 +仍经 Rust `release.distribution`。`make ci-check`、`make test-go-api` 和 +`make check-docs` 均已通过。 --- @@ -544,14 +554,17 @@ CI 检查必须: * 不修改源码; * 明确报告每项 gate; * required tool 缺失时不应伪装成全部通过; -* optional gate 明确标记 optional; +* required tool 缺失或版本不匹配必须失败; * 与实际 Gitea CI 尽量保持一致。 ## 完成记录 `make format` / `make fmt` 保留为显式写入命令,`make ci-check` 和兼容的 -`make ci` 只执行 read-only required gates;Go lint 在工具缺失时明确输出 -optional skipped,OpenAPI、RPC contract 和文档一致性由 `make check-docs` 纳入。 +`make ci` 只执行 read-only required gates;`golangci-lint 2.12.2` 由 +`scripts/ci-versions.sh` 固定,缺失或版本不匹配失败;OpenAPI、RPC contract 和文档 +一致性由 `make check-docs` 纳入。Gitea self-hosted runner 执行相同的 required +Rust/Go/docs 语义,不添加 GitHub Actions。`make ci-check`、`make test-go-api` 和 +`make check-docs` 均已通过。 建议统一覆盖: diff --git a/api/openapi/bat-api.yaml b/api/openapi/bat-api.yaml index 186cf47..fe5dc84 100644 --- a/api/openapi/bat-api.yaml +++ b/api/openapi/bat-api.yaml @@ -20,9 +20,9 @@ paths: summary: Release readiness responses: "200": - description: A release authorized by Rust release.status and fully represented by the local read snapshot is available. + description: A current official release authorized by Rust release.attestation and fully represented by the bound local read snapshot is available. "503": - description: The Rust whole-release distribution health fact or the local read snapshot is not distributable. + description: The Rust current attestation is unavailable, stale, invalid, or the bound local read snapshot is not distributable. /v1/bootstrap: get: summary: Startup resource bootstrap diff --git a/cmd/bat/ffi.go b/cmd/bat/ffi.go index 1c8ad69..a2326de 100644 --- a/cmd/bat/ffi.go +++ b/cmd/bat/ffi.go @@ -9,7 +9,3 @@ func InspectManifest(rawJSON string) (string, error) { func BuildSyncPlan(currentJSON, previousJSON string) (string, error) { return ffi.BuildSyncPlan(currentJSON, previousJSON) } - -func batVersion() (string, error) { - return ffi.Version() -} diff --git a/cmd/bat/sync.go b/cmd/bat/sync.go index d9fd5fa..0e09e22 100644 --- a/cmd/bat/sync.go +++ b/cmd/bat/sync.go @@ -21,6 +21,6 @@ func runSync(args []string) error { return err } - fmt.Fprintln(os.Stdout, result) - return nil + _, err = fmt.Fprintln(os.Stdout, result) + return err } diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 033d3b9..5b529a0 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -371,10 +371,10 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边 - CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。 - 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再读取 - Rust `release.status` 的 whole-release `official_distribution_ready`,最后 - 读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。 - Go 不重新实现 release verifier;普通 current CDN 只有在该 Rust health fact - 和本地只读 manifest 快照都允许时才分发。 + Rust 轻量 `release.attestation`,最后按 attested release/publication/manifest + identity 读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket + 文件)。Go 不重新实现 release verifier;普通 current CDN 只有在 attestation + fresh/ready、分页快照完整且本地只读路径检查都允许时才分发。 - `/v1/releases`、`/v1/distribution` 和受保护的 `/admin/releases/status`、 `/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed 结果;localized 或历史分发不会绕过 Rust 完整性判断。 diff --git a/docs/architecture/resource-release-layout.md b/docs/architecture/resource-release-layout.md index 3692c21..826a578 100644 --- a/docs/architecture/resource-release-layout.md +++ b/docs/architecture/resource-release-layout.md @@ -92,7 +92,7 @@ ### 2.1 双 release 读写边界 -Rust `bat` 的 `release.status` 是 official/localized 的统一只读视图,基于既有 +Rust `bat` 的 `release.status` 是 official/localized 的统一重型只读视图,基于既有 version state、current symlink、release manifest、文件系统和必要的 CAS/reference 元数据计算,不建立第二个 release 数据库。`release.list` 返回两个 namespace 的当前 与历史 release,包含稳定 ID、created/published、source official relation、生命周期、 @@ -382,10 +382,11 @@ Addressables 改写后客户端拼接: 1. `daemon.status` 2. `daemon.doctor` -3. `release.status`,消费 Rust 返回的当前 official `official_distribution_ready` - 及其 `status` / `status_code` / integrity 事实 +3. `release.attestation`,消费 Rust 当前 official 的 `ready`、release/publication/ + manifest identity、verification generation、freshness 和 integrity 事实 4. `catalog.status`(`version.resource_root`、`addressables_root`、app/bundle) -5. `resource.manifest` 分页(url / destination / bytes / blake3) +5. `resource.manifest` 分页(请求携带 release/publication/manifest identity;响应每页 + 返回同一组 identity、generation、total、offset、limit) 6. 在 `resource_root` 上 Lstat 校验 Present / size;该检查只验证 Go 读快照, 不替代 Rust release verifier diff --git a/docs/guides/baseline.md b/docs/guides/baseline.md index a201e88..92c8101 100644 --- a/docs/guides/baseline.md +++ b/docs/guides/baseline.md @@ -45,7 +45,7 @@ make check-docs 说明: 1. 默认 Go 测试只覆盖正式 `bat-api` 依赖的纯 Go 包:`internal/api` 和 `internal/backendrpc`;`make test-go-ffi` / `make test-go-all` 才会包含 FFI 和试验 CLI。 -2. `golangci-lint` 当前仍是 optional gate;命令可用时执行并失败即阻断,不可用时明确输出 skipped 及原因。 +2. `golangci-lint 2.12.2` 是 required gate;版本由 `scripts/ci-versions.sh` 固定,工具缺失或版本不匹配直接失败。 3. `make format` / `make fmt` 会修改工作树;`make ci-check`、`make check`、`make test` 和 `make lint` 不应格式化源码。 4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`。 diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index 81f70c4..0b3fc8b 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -439,7 +439,7 @@ BAT_API_TRUST_PROXY_HEADERS=true 否则保持默认 `false`,`bat-api` 会按 TCP peer IP 做限流和日志归因。应用层访问日志只记录 path,不记录 query string,避免 query token 进入日志。动态 JSON 响应使用 `Cache-Control: no-store`;CDN 字节路径仍使用长期 immutable 缓存。 -不要在生产 env 里设置 `BAT_API_RESOURCE_ROOT`。`bat-api` 会按 `BAT_API_REFRESH_INTERVAL` 周期通过 RPC 重新读取 `catalog.status` / `resource.manifest`,从而跟随 Rust `bat` 切换 `current -> versions/`。 +不要在生产 env 里设置 `BAT_API_RESOURCE_ROOT`。`bat-api` 会按 `BAT_API_REFRESH_INTERVAL` 周期通过 RPC 读取当前 official `release.attestation`,再以同一 release/publication/manifest identity 请求 `resource.manifest`,从而跟随 Rust `bat` 切换 `current -> versions/`;attestation 过期或分页代际不一致时 fail closed。 ### 健康检查 diff --git a/docs/guides/development.md b/docs/guides/development.md index 22ac7d9..b1d5591 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -143,14 +143,16 @@ UnityFS / AssetBundle / Addressables / TypeTree 解析当前按路线图继续 make ci-check ``` -`make ci-check` 是只读门禁入口;`make format` / `make fmt` 才会修改源码。缺少 -`golangci-lint` 时,Go lint 作为 optional gate 明确输出 skipped,不会伪报全部门禁通过。 +`make ci-check` 是只读门禁入口;`make format` / `make fmt` 才会修改源码。 +required 的 `golangci-lint 2.12.2` 由 `scripts/ci-versions.sh` 固定,缺失或版本不匹配 +都会失败,不会伪报全部门禁通过。 Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准: - **同步/运维命令行** = Rust `bat`(近乎全自动) - **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api`(`make build-go-api`) -- **默认 Go 门禁** = `make ci-check` 中的纯 Go API test/vet/build(无 FFI) +- **默认 Go 门禁** = `make ci-check` 中的纯 Go API test/vet/build 和 + `golangci-lint 2.12.2`(无 FFI) - 试验 CLI 产物为 `bin/bat-go`(`make build-go-cli`),**禁止**与 Rust `bat` 重名 - 修改 FFI 时再跑 `make test-go-ffi` diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index 12d1f16..8e42f69 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -104,8 +104,8 @@ contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主 | `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 | | `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 | | `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 | -| `resource.manifest` | 已实现 | `{ "offset": 0, "limit": 100 }` | 当前 download manifest 分页。 | -| `resource.list` | 已实现 | `{ "offset": 0, "limit": 100 }` | `resource.manifest` 的兼容别名。 | +| `resource.manifest` | 已实现 | `{ "release_id": "...", "expected_publication_identity": "...", "expected_manifest_identity": "...", "offset": 0, "limit": 100 }` | 绑定一个 Rust attested official generation 的 download manifest 分页。 | +| `resource.list` | 已实现 | 同 `resource.manifest` | `resource.manifest` 的兼容别名。 | | `resource.index` | 已实现 | `{ "offset": 0, "limit": 100, "type": "asset_bundle", "hash": "...", "path_pattern": "*", "release_id": "...", "platform": "windows", "destination": "...", "archive_entry": "...", "parse_status": "parsed", "format": "json" }` | 当前 `ResourceRepository` 分页/过滤查询。 | `resource.repair` 会开启本地 manifest audit + repair,不继承 `force`。 @@ -119,6 +119,14 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且 属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是 `1..=1000`,非法参数返回 `BAT-ERR-700002`。 +`resource.manifest` 的请求必须携带由 `release.attestation` 返回的 +`release_id`、`expected_publication_identity` 和 `expected_manifest_identity`。 +每一页返回 `release_id`、`resource_root`、`manifest_version`、 +`publication_identity`、`mapping_identity`、`manifest_identity`、`generation`、 +`total_entries`、`offset`、`limit` 和 `entries`。Rust 在当前 release 切换或 identity +不匹配时拒绝请求;Go 会逐页验证这些字段、页 offset、total 和最终 entry count, +任何一页不一致都会丢弃整个候选快照。 + `resource.index` 的 `entries[]` 是 `Resource` JSON,除 `id`、`local_path`、 `entry` 外会包含 `metadata`:`official_release_id`、`platform`、 `bundle_path`、`archive_entries`、`parse_statuses`、`unity_versions`、 @@ -176,7 +184,8 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且 | 方法 | 状态 | params | data | |---|---|---|---| -| `release.status` | 已实现 | `null` | official/localized current、source relation、match、历史 release 和 manifest/artifact/distribution integrity 统一视图;`official_distribution_ready` 是 bat-api 普通 current release readiness/distribution 使用的 Rust-owned whole-release health fact。 | +| `release.attestation` | 已实现 | `null` | 当前 official 的轻量 health/publication proof:`available`、`ready`、`channel`、`release_id`、`resource_root`、`publication_identity`、`mapping_identity`、`manifest_identity`、`entry_count`、`integrity_status`、`status`、`status_code`、`verification_generation`、`verified_at`、`max_age_seconds` 和 diagnostics。只读取 current、publication anchor、manifest 元数据与 freshness,不扫描历史 release 或计算资源文件 BLAKE3。 | +| `release.status` | 已实现 | `null` | official/localized current、source relation、match、历史 release 和 manifest/artifact/distribution integrity 的重型管理诊断,仍返回管理侧 `official_distribution_ready`;普通 current bat-api readiness 使用 `release.attestation`。 | | `release.list` | 已实现 | `{ "channel": "official" }` 或 `{ "channel": "localized" }`,可省略 | 对应 namespace 的历史 release 摘要,包含 stable ID、created/published、current pointer、`rollback_available`、lifecycle、`stale`/`damaged`/`referenced`/`unknown`、legacy 和诊断。 | | `release.distribution` | 已实现 | `{ "channel": "official", "release_id": "...", "destination": "...", "offset": 0, "limit": 1000 }`,均可省略 | Rust 只选择具有独立 `official-distribution-publication.json` 且 identity 与当前 manifest 一致的 verified `resource_root`;有 `destination` 时是 single-entry lookup,响应固定 `total=1, offset=0, limit=1, entries.length=1`,使用 published mapping identity/destination index,只校验该实际文件的 bytes/BLAKE3,不重新执行全量映射或资源 audit;无 `destination` 时保留管理查询分页语义。localized 还必须匹配 source official 的 published identity,并使用发布时生成的实际字节 metadata,不复用 official size/hash;默认 channel 为 official,选择失败返回 `available=false`,不跨 channel fallback。 | | `release.cleanup` | 已实现 | dry-run `{ "execute": false }`;执行 `{ "execute": true, "plan_id": "..." }` | cleanup plan、candidate/retain reasons、blocking references 和 removed paths;执行前会重新生成并比对 `plan_id`。 | @@ -565,8 +574,9 @@ CLI 对应关系: | Go 接口 | 允许调用的 RPC | 用途 | |---|---|---| -| `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`resource.manifest` | 启动发现、周期刷新和资源分发基础数据 | -| `ReleaseStatusBackend` | `release.status` | 启动发现和周期刷新使用 Rust-owned whole-release distribution health;Go 不重新实现 verifier | +| `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`release.attestation`、绑定后的 `resource.manifest` | 启动发现、周期刷新和资源分发基础数据 | +| `AttestationBackend` | `release.attestation` | current official 轻量 health/publication proof;不触发历史 release 扫描 | +| `ReleaseStatusBackend` | `release.status` | 鉴权管理面的 official/localized 重型 release 诊断;Go 不重新实现 verifier | | `ControlBackend` | `daemon.restart`、`daemon.reload`、`daemon.refresh`、`resource.sync`、`resource.verify`、`resource.repair`、`catalog.refresh` | 鉴权后的管理控制白名单 | | `ScheduleBackend` | `schedule.list`、`schedule.add`、`schedule.update`、`schedule.remove`、`schedule.run` | 鉴权后的 dashboard 调度计划控制 | | `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 | diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 5a99410..1227144 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -76,9 +76,11 @@ launcher 资源引导兼容、只读 CDN path、readiness、OpenAPI、鉴权管 dashboard;翻译任务和 Rust-owned TM 的 summary/query/confirm 也通过 typed RPC 转发。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。 普通 current release 的 readiness、bootstrap、release summary 和 CDN 共用 -Rust `release.status.official_distribution_ready` whole-release health fact;Go -只建立 manifest 的读快照,不复制 Rust verifier。`make ci-check` 是只读统一门禁, -缺少 `golangci-lint` 时明确报告 optional skipped。 +Rust `release.attestation` 是 current official 的轻量 health/publication proof,带 +release/publication/manifest identity、verification generation、freshness 和诊断;Go +只建立绑定同一代际的 manifest 读快照,不复制 Rust verifier。`release.status` 仍保留 +为重型管理诊断。`make ci-check` 是只读统一门禁,required `golangci-lint 2.12.2` +缺失或版本不匹配直接失败。 仍需完成: diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index e0330a7..f41e4ce 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -61,11 +61,11 @@ | ID | 约定 | |---|---| | F | 版本/清单经 **`bat.sock` JSON-RPC**(`--socket`);不读 daemon 内部状态文件 | -| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再 **`release.status`**,再 catalog/manifest | +| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再轻量 **`release.attestation`**,再 catalog/manifest;manifest 请求绑定 attested release/publication/manifest identity | | H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 | | I | 生产中 Rust `bat` 与 `bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 | | J | Go 索引以 **manifest + 磁盘 Present/size** 建立可读快照,但不将其当作 release integrity | -| J2 | RPC 状态以 Rust 返回的 `status` / `status_code` 为准;`release.status.official_distribution_ready` 是普通 current 分发的 whole-release 授权事实,`bat-api` 只读消费,不自行推导 verifier | +| J2 | 普通 current 分发以 Rust `release.attestation` 的 `ready`、identity、freshness 和 integrity/status code 为准;`bat-api` 只读消费,不自行推导 verifier;`release.status` 保留为重型管理诊断 | ### 进程配置 @@ -86,7 +86,7 @@ | O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 | | P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` | | Q | 空目录标明 reserved empty | -| R | 默认门禁:`make ci-check`;其中 Go 使用纯 API test/vet/build(无 FFI),`golangci-lint` 为明确报告的 optional gate | +| R | 默认门禁:`make ci-check`;其中 Go 使用纯 API test/vet/build、required `golangci-lint 2.12.2`(无 FFI),缺失或版本不匹配失败 | --- @@ -95,7 +95,7 @@ | 组件 | 路径 | 状态 | 说明 | |---|---|---|---| | Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 | -| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`release.status/list/distribution/cleanup`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`localized.publish` 的 typed params 支持 `translation_file`、`from_worker` 或 `patch_manifest` 三选一;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 | +| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`release.attestation/status/list/distribution/cleanup`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`resource.manifest` typed params 固定 release/publication/manifest identity;`localized.publish` 的 typed params 支持 `translation_file`、`from_worker` 或 `patch_manifest` 三选一;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 | | 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + `/v1/releases` + `/v1/distribution` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + release/localized/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` | diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index 95a1763..b85d30b 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -7,17 +7,19 @@ use bat_core::{ApiError, ErrorCode}; use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY; use bat_infrastructure::{ apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, - apply_unityfs_text_asset_patch_file, build_release_list, build_release_status, - changed_endpoint_urls, cleanup_releases, completed_worker_translation_workbench, - diff_extended_snapshot, export_translation_workbench, gc_orphan_staging_with_cas_root, - get_translation_entry, inspect_localized_release_artifact, lexical_absolute, - localized_patch_operations_with_glossary_path, open_append_file, read_download_manifest_at, + apply_unityfs_text_asset_patch_file, build_official_distribution_attestation, + build_release_list, build_release_status, changed_endpoint_urls, cleanup_releases, + completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench, + gc_orphan_staging_with_cas_root, get_translation_entry, inspect_localized_release_artifact, + lexical_absolute, localized_patch_operations_with_glossary_path, + official_distribution_mapping_identity, open_append_file, read_download_manifest_at, read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, select_release_distribution, set_translation, set_translation_checked_with_glossary_path, unset_translation, validate_output_root, validate_runtime_state_dir, - validate_translation_workbench_with_glossary_path, write_file_atomic, + validate_translation_workbench_with_glossary_path, + verify_and_record_official_distribution_attestation, write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport, OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService, @@ -1155,6 +1157,25 @@ struct LocalizedRollbackRpcParams { localized_release_id: Option, } +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct ResourceManifestRpcParams { + #[serde(default)] + release_id: Option, + #[serde(default)] + expected_publication_identity: Option, + #[serde(default)] + expected_manifest_identity: Option, + #[serde(default)] + offset: usize, + #[serde(default = "default_rpc_manifest_limit")] + limit: usize, +} + +fn default_rpc_manifest_limit() -> usize { + 100 +} + // 规范方法名采用国际惯例的 `.`。`bat.*` 保留为向后兼容别名。 const RPC_METHOD_STATUS: &str = "daemon.status"; const RPC_METHOD_STOP: &str = "daemon.stop"; @@ -1199,6 +1220,7 @@ const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status"; const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish"; const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback"; const RPC_METHOD_RELEASE_STATUS: &str = "release.status"; +const RPC_METHOD_RELEASE_ATTESTATION: &str = "release.attestation"; const RPC_METHOD_RELEASE_LIST: &str = "release.list"; const RPC_METHOD_RELEASE_DISTRIBUTION: &str = "release.distribution"; const RPC_METHOD_RELEASE_CLEANUP: &str = "release.cleanup"; @@ -1982,6 +2004,18 @@ fn dispatch_rpc_method( "resource.state", build_resource_state_report(state_dir), ), + RPC_METHOD_RELEASE_ATTESTATION => { + let _sync_guard = tasks + .sync_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + rpc_envelope_from_result( + request_id, + RPC_METHOD_RELEASE_ATTESTATION, + build_official_distribution_attestation(&tasks.base_config.output_root) + .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), + ) + } RPC_METHOD_SCHEDULE_LIST => { let params = request .params @@ -2084,23 +2118,27 @@ fn dispatch_rpc_method( enqueue_task_envelope(tasks, TaskKind::Repair, false, request_id) } RPC_METHOD_RESOURCE_MANIFEST => { - let (offset, limit) = match rpc_page_params(request.params.as_ref()) { - Ok(page) => page, - Err(error) => { - return rpc_envelope_error( - request_id, - ApiError::new( - ErrorCode::RPC_INVALID_PARAMS, - "resource.manifest", - error.to_string(), - ), - ) - } + let params = match rpc_optional_struct_params::( + request.params.as_ref(), + RPC_METHOD_RESOURCE_MANIFEST, + ) { + Ok(params) => params, + Err(error) => return rpc_envelope_error(request_id, error), + }; + if params.limit == 0 || params.limit > 1000 { + return rpc_envelope_error( + request_id, + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + RPC_METHOD_RESOURCE_MANIFEST, + "limit 必须在 1..=1000 范围内", + ), + ); }; rpc_envelope_from_result( request_id, - "resource.manifest", - build_resource_manifest_report(state_dir, offset, limit), + RPC_METHOD_RESOURCE_MANIFEST, + build_resource_manifest_report(state_dir, params), ) } RPC_METHOD_RESOURCE_INDEX => { @@ -2969,39 +3007,102 @@ fn build_catalog_diff_report(state_dir: &Path) -> anyhow::Result anyhow::Result { - let (_, version_state) = read_daemon_resource_state(state_dir)?; + let (status_file, version_state) = read_daemon_resource_state(state_dir)?; let current = version_state .as_ref() .and_then(|state| state.current_completed_version.as_ref()); let Some(record) = current else { return Ok(serde_json::json!({ "available": false })); }; + if params + .release_id + .as_deref() + .is_some_and(|release_id| release_id != record.id) + { + return Err(anyhow::anyhow!( + "resource.manifest release_id 与当前 release 不一致:expected={:?} current={}", + params.release_id, + record.id + )); + } + let manifest_path = record.resource_root.join("official-download-manifest.json"); + let Some(manifest_bytes) = + read_file_no_symlink(&manifest_path, "官方下载 manifest").map_err(anyhow::Error::msg)? + else { + return Ok(serde_json::json!({ + "available": false, + "release_id": record.id, + "resource_root": record.resource_root, + })); + }; let manifest = read_download_manifest_at(&record.resource_root).map_err(anyhow::Error::msg)?; let Some(manifest) = manifest else { return Ok(serde_json::json!({ "available": false, + "release_id": record.id, "resource_root": record.resource_root, })); }; + let manifest_identity = blake3::hash(&manifest_bytes).to_hex().to_string(); + let mapping_identity = official_distribution_mapping_identity(&manifest); + let attestation = status_file.as_ref().and_then(|status| { + build_official_distribution_attestation(&status.resource_output_root).ok() + }); + let publication_identity = attestation + .as_ref() + .map(|report| report.publication_identity.clone()) + .filter(|identity| !identity.is_empty()) + .unwrap_or_else(|| format!("manifest-v1-{manifest_identity}")); + let verification_generation = attestation + .as_ref() + .map(|report| report.verification_generation) + .unwrap_or(0); + if params + .expected_manifest_identity + .as_deref() + .is_some_and(|expected| expected != manifest_identity) + { + return Err(anyhow::anyhow!( + "resource.manifest manifest_identity 不一致:expected={:?} actual={}", + params.expected_manifest_identity, + manifest_identity + )); + } + if params + .expected_publication_identity + .as_deref() + .is_some_and(|expected| expected != publication_identity) + { + return Err(anyhow::anyhow!( + "resource.manifest publication_identity 不一致:expected={:?} actual={}", + params.expected_publication_identity, + publication_identity + )); + } let total_entries = manifest.entries.len(); // BTreeMap 按 URL 有序迭代,分页结果稳定。 let entries: Vec<_> = manifest .entries .values() - .skip(offset) - .take(limit) + .skip(params.offset) + .take(params.limit) .cloned() .collect(); Ok(serde_json::json!({ "available": true, + "channel": "official", + "release_id": record.id, "resource_root": record.resource_root, "manifest_version": manifest.version, + "publication_identity": publication_identity, + "mapping_identity": mapping_identity, + "manifest_identity": manifest_identity, + "generation": verification_generation, "total_entries": total_entries, - "offset": offset, - "limit": limit, + "offset": params.offset, + "limit": params.limit, "entries": entries, })) } @@ -5457,6 +5558,7 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result { zip_error: item.zip_error.clone(), }) .collect::>(); + verify_and_record_official_distribution_attestation(&config)?; let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate && update_report.local_manifest_repair_needed_count == 0 && verification.is_clean(); diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index 17cb171..d81ef54 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -4453,6 +4453,9 @@ fn dispatch_resource_manifest_paginates() { assert_eq!(value["data"]["available"], true); assert_eq!(value["data"]["total_entries"], 3); assert_eq!(value["data"]["offset"], 1); + assert_eq!(value["data"]["release_id"], "v-current"); + assert!(value["data"]["manifest_identity"].as_str().is_some()); + assert!(value["data"]["generation"].as_u64().is_some()); let entries = value["data"]["entries"].as_array().unwrap(); assert_eq!(entries.len(), 2); assert_eq!(entries[0]["destination"], "b"); @@ -4486,6 +4489,24 @@ fn dispatch_resource_manifest_paginates() { let entries = value["data"]["entries"].as_array().unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0]["destination"], "c"); + + let envelope = dispatch_rpc_method( + &rpc_request( + "resource.manifest", + Some(serde_json::json!({ + "release_id": "v-current", + "expected_manifest_identity": "wrong-generation", + "offset": 0, + "limit": 1, + })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-man-4".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false); } fn write_resource_index_fixture(repository_path: &Path) { diff --git a/infrastructure/src/bin/bat/task_registry.rs b/infrastructure/src/bin/bat/task_registry.rs index fd41e61..dc2c304 100644 --- a/infrastructure/src/bin/bat/task_registry.rs +++ b/infrastructure/src/bin/bat/task_registry.rs @@ -603,6 +603,14 @@ pub(super) fn run_task_worker( ) }; run_result + .and_then(|report| { + if job.kind == TaskKind::Verify { + bat_infrastructure::verify_and_record_official_distribution_attestation( + &job.config, + )?; + } + Ok(report) + }) .map(|report| serde_json::to_value(&report).map_err(anyhow::Error::from)) .and_then(|result| result) }; diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 8e9ee75..221560e 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -82,13 +82,15 @@ pub use official_changes::{ pub use official_download::{ official_distribution_mapping_identity, read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references, DownloadError, - OfficialCasReuseReferenceManifest, OfficialDownloadManifest, OfficialDownloadManifestEntry, - OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport, - OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, - OfficialResourceHashAlgorithm, OfficialResourceHashVerification, OfficialResourcePullItem, - OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport, - OfficialResourcePullService, OfficialResourcePullStatus, OfficialResourceReuseWarning, - OfficialResourceVerification, OFFICIAL_CAS_REUSE_REFERENCES_FILE, + OfficialCasReuseReferenceManifest, OfficialDistributionAttestation, OfficialDownloadManifest, + OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem, + OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus, + OfficialLocalVerificationReport, OfficialResourceHashAlgorithm, + OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress, + OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService, + OfficialResourcePullStatus, OfficialResourceReuseWarning, OfficialResourceVerification, + OFFICIAL_CAS_REUSE_REFERENCES_FILE, OFFICIAL_DISTRIBUTION_ATTESTATION_FILE, + OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS, OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION, OFFICIAL_DISTRIBUTION_PUBLICATION_FILE, }; pub use official_game_main_config::OfficialGameMainConfigBootstrapService; @@ -130,13 +132,13 @@ pub use official_textunit_queue::{ pub use official_update::{ cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging, gc_orphan_staging_with_cas_root, read_bootstrap_cache, read_snapshot, read_version_state, - write_bootstrap_cache, write_snapshot, write_version_state, ExtendedSnapshotDelta, - GameMainConfigSnapshot, LauncherMetadataSnapshot, LocalizedReleaseStatus, - OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, - OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig, - OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, - OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, - ResolvedBootstrap, + verify_and_record_official_distribution_attestation, write_bootstrap_cache, write_snapshot, + write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot, + LocalizedReleaseStatus, OfficialBootstrapCache, OfficialEndpointMarkerRole, + OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource, + OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, + OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, + OfficialVersionRecord, OfficialVersionState, ResolvedBootstrap, }; pub use patch_ops::{ apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, @@ -151,7 +153,8 @@ pub use path_security::{ }; pub use release_flow::ReleaseFlowStatusCode; pub use release_ops::{ - build_release_list, build_release_status, cleanup_releases, select_release_distribution, + build_official_distribution_attestation, build_release_list, build_release_status, + cleanup_releases, select_release_distribution, OfficialDistributionAttestationReport, ReleaseCleanupParams, ReleaseCleanupReport, ReleaseDistributionEntry, ReleaseDistributionPage, ReleaseDistributionParams, ReleaseListParams, ReleaseStatusReport, ReleaseSummary, }; diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index d8444fa..a65e5ca 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -71,6 +71,12 @@ const DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json"; const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json"; /// Independent publication fact for the official distribution manifest. pub const OFFICIAL_DISTRIBUTION_PUBLICATION_FILE: &str = "official-distribution-publication.json"; +/// Current official distribution verification result. +pub const OFFICIAL_DISTRIBUTION_ATTESTATION_FILE: &str = "official-distribution-attestation.json"; +/// Persisted attestation schema version. +pub const OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION: u32 = 1; +/// Attestations older than this are no longer allowed to authorize current CDN. +pub const OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS: u64 = 900; /// 记录一个已发布官方 release 获取的 CAS 引用。 pub const OFFICIAL_CAS_REUSE_REFERENCES_FILE: &str = "official-cas-reuse-references.json"; const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1; @@ -566,6 +572,154 @@ pub(crate) struct OfficialDistributionPublicationAnchor { const OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION: u32 = 1; +/// Rust-owned lightweight proof that the current official publication is safe +/// for the read-only distribution path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialDistributionAttestation { + /// Attestation schema version. + pub version: u32, + /// Distribution channel; currently always `official`. + pub channel: String, + /// Stable official release ID. + pub official_release_id: String, + /// Published version root this result describes. + pub resource_root: PathBuf, + /// Identity of the publication anchor and its manifest generation. + pub publication_identity: String, + /// Identity of the complete destination mapping. + pub mapping_identity: String, + /// BLAKE3 identity of the manifest bytes. + pub manifest_identity: String, + /// Number of entries in the bound manifest. + pub entry_count: u64, + /// `verified`, `stale`, `invalid`, or `unavailable`. + pub integrity_status: String, + /// Human-readable stable state label. + pub status: String, + /// Namespaced status code consumed by RPC clients. + pub status_code: String, + /// Whether this attestation currently authorizes distribution. + pub ready: bool, + /// Monotonic verification generation for this published root. + pub verification_generation: u64, + /// Time of the last successful full local verification. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verified_at: Option, + /// Freshness window used by the lightweight RPC reader. + pub max_age_seconds: u64, + /// Diagnostics retained with the result. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +pub(crate) fn official_distribution_publication_identity( + anchor: &OfficialDistributionPublicationAnchor, +) -> String { + format!( + "odp-v1-{}-{}", + anchor.mapping_identity, anchor.manifest_identity + ) +} + +fn official_distribution_attestation_status_code(integrity_status: &str) -> &'static str { + match integrity_status { + "verified" => "distribution.ready", + "stale" => "distribution.attestation_stale", + "invalid" => "distribution.attestation_invalid", + _ => "distribution.attestation_unavailable", + } +} + +fn official_distribution_attestation_status(integrity_status: &str) -> &'static str { + match integrity_status { + "verified" => "ready", + "stale" => "stale", + "invalid" => "invalid", + _ => "unavailable", + } +} + +pub(crate) fn read_official_distribution_attestation_at( + release_root: &Path, +) -> Result, String> { + let path = release_root.join(OFFICIAL_DISTRIBUTION_ATTESTATION_FILE); + let Some(bytes) = read_file_no_symlink(&path, "官方 distribution attestation")? else { + return Ok(None); + }; + let attestation: OfficialDistributionAttestation = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析官方 distribution attestation 失败:{error}"))?; + if attestation.version != OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION { + return Err(format!( + "不支持的官方 distribution attestation 版本:{}", + attestation.version + )); + } + Ok(Some(attestation)) +} + +/// Records a verification result for one already published official root. +/// +/// The publication anchor is reused as the immutable generation identity. +/// The caller chooses `verified` only after the existing full local audit has +/// passed; this function itself never turns a partial audit into a healthy +/// result. +pub(crate) fn write_official_distribution_attestation_at( + release_root: &Path, + official_release_id: &str, + integrity_status: &str, + diagnostics: Vec, +) -> Result { + if !matches!( + integrity_status, + "verified" | "stale" | "invalid" | "unavailable" + ) { + return Err(format!( + "不支持的官方 distribution attestation 状态:{integrity_status}" + )); + } + ensure_safe_directory_path(release_root, "官方 distribution attestation 根目录")?; + let anchor = verify_official_distribution_publication_at(release_root, official_release_id)? + .ok_or_else(|| { + format!( + "官方 distribution attestation 缺少 publication anchor:{}", + release_root.display() + ) + })?; + let previous_generation = read_official_distribution_attestation_at(release_root)? + .map(|previous| previous.verification_generation) + .unwrap_or(0); + let verified_at = (integrity_status == "verified").then_some(unix_seconds_now()); + let attestation = OfficialDistributionAttestation { + version: OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION, + channel: "official".to_string(), + official_release_id: official_release_id.to_string(), + resource_root: release_root.to_path_buf(), + publication_identity: official_distribution_publication_identity(&anchor), + mapping_identity: anchor.mapping_identity, + manifest_identity: anchor.manifest_identity, + entry_count: anchor.entry_count, + integrity_status: integrity_status.to_string(), + status: official_distribution_attestation_status(integrity_status).to_string(), + status_code: official_distribution_attestation_status_code(integrity_status).to_string(), + ready: integrity_status == "verified", + verification_generation: previous_generation.saturating_add(1), + verified_at, + max_age_seconds: OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS, + diagnostics, + }; + let path = release_root.join(OFFICIAL_DISTRIBUTION_ATTESTATION_FILE); + ensure_safe_file_target(release_root, &path, "官方 distribution attestation")?; + let bytes = serde_json::to_vec_pretty(&attestation) + .map_err(|error| format!("序列化官方 distribution attestation 失败:{error}"))?; + write_file_atomic( + &path, + &bytes, + STATE_FILE_MODE, + "官方 distribution attestation", + )?; + Ok(attestation) +} + /// Writes the independent publication anchor after the complete official /// release verification has succeeded. /// diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index 1693265..2304872 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -14,7 +14,8 @@ use crate::official_changes::{ OfficialResourceChangeSummary, }; use crate::official_download::{ - write_official_distribution_publication_anchor_at, OFFICIAL_CAS_REUSE_REFERENCES_FILE, + write_official_distribution_attestation_at, write_official_distribution_publication_anchor_at, + OfficialDistributionAttestation, OFFICIAL_CAS_REUSE_REFERENCES_FILE, OFFICIAL_DISTRIBUTION_PUBLICATION_FILE, }; use crate::official_game_main_config::{ @@ -1785,6 +1786,28 @@ impl OfficialUpdateService { &active_resource_root, &snapshot_path, )?; + if let Some(audit) = local_audit.as_ref() { + let integrity_status = if audit.is_clean() { + "verified" + } else { + "invalid" + }; + let diagnostics = if audit.is_clean() { + Vec::new() + } else { + vec![format!( + "本地 manifest 审计失败:{} 项需要修复", + audit.repair_needed_count() + )] + }; + write_official_distribution_attestation_at( + &active_resource_root, + &active_release_id, + integrity_status, + diagnostics, + ) + .map_err(anyhow::Error::msg)?; + } let active_launcher_bootstrap_path = active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE); if bootstrap.is_some() @@ -2015,6 +2038,13 @@ impl OfficialUpdateService { &publish_plan.id, ) .map_err(anyhow::Error::msg)?; + write_official_distribution_attestation_at( + &publish_plan.staging_path, + &publish_plan.id, + "verified", + Vec::new(), + ) + .map_err(anyhow::Error::msg)?; progress(OfficialUpdateProgress::new( "snapshot", format!("写入快照 {}", staging_snapshot_path.display()), @@ -2180,6 +2210,58 @@ impl OfficialUpdateService { } } +/// Runs the explicit full local verification and records its result for the +/// lightweight current-distribution health RPC. +/// +/// This is intentionally called only by the explicit verify task/command. The +/// high-frequency health path reads the resulting attestation and never hashes +/// resource artifacts. +pub fn verify_and_record_official_distribution_attestation( + config: &OfficialUpdateConfig, +) -> anyhow::Result { + let version_state = read_version_state(&config.version_state_path())? + .ok_or_else(|| anyhow::anyhow!("官方版本状态不存在,无法记录 distribution attestation"))?; + let record = version_state + .current_completed_version + .as_ref() + .ok_or_else(|| { + anyhow::anyhow!("没有当前已发布官方 release,无法记录 distribution attestation") + })?; + let resource_root = OfficialPublishLayout::new(&config.output_root) + .active_resource_root() + .map_err(anyhow::Error::msg)?; + if resource_root != record.resource_root { + return Err(anyhow::anyhow!( + "current resource root 与版本状态不一致:current={} state={}", + resource_root.display(), + record.resource_root.display() + )); + } + let verification = + OfficialResourcePullService::with_curl_command(&resource_root, &config.curl_command) + .with_proxy_config(config.curl_proxy.clone()) + .verify_local_download_manifest() + .map_err(anyhow::Error::msg)?; + let diagnostics = verification + .items + .iter() + .filter(|item| !item.status.is_verified()) + .map(|item| format!("{}: {}", item.destination.display(), item.status.as_str())) + .collect::>(); + let integrity_status = if verification.is_clean() { + "verified" + } else { + "invalid" + }; + write_official_distribution_attestation_at( + &resource_root, + &record.id, + integrity_status, + diagnostics, + ) + .map_err(anyhow::Error::msg) +} + fn run_post_sync_resource_handoff( previous_resource_root: Option<&Path>, current_resource_root: &Path, diff --git a/infrastructure/src/release_ops.rs b/infrastructure/src/release_ops.rs index 28db1ad..251466a 100644 --- a/infrastructure/src/release_ops.rs +++ b/infrastructure/src/release_ops.rs @@ -11,9 +11,11 @@ use crate::localized_patch::{ LOCALIZED_VERSIONS_DIR, }; use crate::official_download::{ - read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references, - verify_official_distribution_publication_at, OfficialDownloadManifest, - OfficialDownloadManifestEntry, + official_distribution_publication_identity, read_cas_reuse_reference_manifest_at, + read_download_manifest_at, read_official_distribution_attestation_at, + release_cas_reuse_references, verify_official_distribution_publication_at, + OfficialDownloadManifest, OfficialDownloadManifestEntry, + OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS, }; use crate::official_update::{read_version_state, OfficialVersionRecord, OfficialVersionState}; use crate::path_security::{ @@ -24,6 +26,7 @@ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; const OFFICIAL_CURRENT_LINK: &str = "current"; const OFFICIAL_STAGING_DIR: &str = ".staging"; @@ -148,6 +151,47 @@ pub struct ReleaseStatusReport { pub releases: Vec, } +/// Lightweight current-official distribution health proof. +/// +/// Unlike `ReleaseStatusReport`, this report never scans historical releases +/// or hashes resource artifacts. It only evaluates the current pointer, +/// publication anchor, persisted attestation, and attestation freshness. +#[derive(Debug, Clone, Serialize)] +pub struct OfficialDistributionAttestationReport { + /// Whether a current official version record exists. + pub available: bool, + /// Distribution channel. + pub channel: String, + /// Current official release ID. + pub release_id: String, + /// Current versioned resource root. + pub resource_root: String, + /// Publication/generation identity. + pub publication_identity: String, + /// Complete mapping identity. + pub mapping_identity: String, + /// Manifest byte identity. + pub manifest_identity: String, + /// Number of bound manifest entries. + pub entry_count: u64, + /// `verified`, `stale`, `invalid`, or `unavailable`. + pub integrity_status: String, + /// Stable state label. + pub status: String, + /// Stable namespaced status code. + pub status_code: String, + /// Whether current official distribution may be served. + pub ready: bool, + /// Verification generation from the persisted attestation. + pub verification_generation: u64, + /// Last successful full verification time. + pub verified_at: Option, + /// Freshness window used by the reader. + pub max_age_seconds: u64, + /// Diagnostics explaining a blocked result. + pub diagnostics: Vec, +} + /// A page of resources from a Rust-verified release choice. #[derive(Debug, Clone, Serialize)] pub struct ReleaseDistributionPage { @@ -309,6 +353,196 @@ pub fn build_release_status( }) } +/// Builds the lightweight attestation for the current official release. +pub fn build_official_distribution_attestation( + official_root: &Path, +) -> anyhow::Result { + let state = read_version_state(&official_root.join(OFFICIAL_VERSION_STATE_FILE))?; + let Some(record) = state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()) + else { + return Ok(OfficialDistributionAttestationReport { + available: false, + channel: "official".to_string(), + release_id: String::new(), + resource_root: String::new(), + publication_identity: String::new(), + mapping_identity: String::new(), + manifest_identity: String::new(), + entry_count: 0, + integrity_status: "unavailable".to_string(), + status: "unavailable".to_string(), + status_code: "distribution.attestation_unavailable".to_string(), + ready: false, + verification_generation: 0, + verified_at: None, + max_age_seconds: OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS, + diagnostics: vec!["没有当前已发布官方 release".to_string()], + }); + }; + + let mut report = OfficialDistributionAttestationReport { + available: true, + channel: "official".to_string(), + release_id: record.id.clone(), + resource_root: record.resource_root.display().to_string(), + publication_identity: String::new(), + mapping_identity: String::new(), + manifest_identity: String::new(), + entry_count: 0, + integrity_status: "unavailable".to_string(), + status: "unavailable".to_string(), + status_code: "distribution.attestation_unavailable".to_string(), + ready: false, + verification_generation: 0, + verified_at: None, + max_age_seconds: OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS, + diagnostics: Vec::new(), + }; + + let current_id = read_managed_current_id( + &official_root.join(OFFICIAL_CURRENT_LINK), + OFFICIAL_VERSIONS_DIR, + ); + if current_id.as_deref() != Some(record.id.as_str()) { + report.integrity_status = "invalid".to_string(); + report.status = "invalid".to_string(); + report.status_code = "distribution.attestation_invalid".to_string(); + report.diagnostics.push(format!( + "current 指针与版本状态不一致:pointer={:?} state={}", + current_id, record.id + )); + } + if let Err(error) = ensure_path_within_root(official_root, &record.resource_root) { + report.integrity_status = "invalid".to_string(); + report.status = "invalid".to_string(); + report.status_code = "distribution.attestation_invalid".to_string(); + report.diagnostics.push(error); + } + if let Err(error) = + ensure_safe_directory_path(&record.resource_root, "当前官方 distribution 根目录") + { + report.integrity_status = "invalid".to_string(); + report.status = "invalid".to_string(); + report.status_code = "distribution.attestation_invalid".to_string(); + report.diagnostics.push(error); + } + + let anchor = + match verify_official_distribution_publication_at(&record.resource_root, &record.id) { + Ok(Some(anchor)) => { + report.publication_identity = official_distribution_publication_identity(&anchor); + report.mapping_identity = anchor.mapping_identity.clone(); + report.manifest_identity = anchor.manifest_identity.clone(); + report.entry_count = anchor.entry_count; + Some(anchor) + } + Ok(None) => { + report + .diagnostics + .push("缺少 publication anchor".to_string()); + None + } + Err(error) => { + report.diagnostics.push(error); + None + } + }; + + let attestation = match read_official_distribution_attestation_at(&record.resource_root) { + Ok(attestation) => attestation, + Err(error) => { + report.diagnostics.push(error); + None + } + }; + let Some(attestation) = attestation else { + if report.integrity_status != "invalid" { + report.integrity_status = "unavailable".to_string(); + report.status = "unavailable".to_string(); + report.status_code = "distribution.attestation_unavailable".to_string(); + } + report + .diagnostics + .push("缺少当前 release attestation".to_string()); + return Ok(report); + }; + + report.integrity_status = attestation.integrity_status.clone(); + report.status = attestation.status.clone(); + report.status_code = attestation.status_code.clone(); + report.publication_identity = attestation.publication_identity.clone(); + report.mapping_identity = attestation.mapping_identity.clone(); + report.manifest_identity = attestation.manifest_identity.clone(); + report.entry_count = attestation.entry_count; + report.verification_generation = attestation.verification_generation; + report.verified_at = attestation.verified_at; + report.max_age_seconds = if attestation.max_age_seconds == 0 { + OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS + } else { + attestation.max_age_seconds + }; + report.diagnostics.extend(attestation.diagnostics); + + let identity_matches = anchor.as_ref().is_some_and(|anchor| { + attestation.channel == "official" + && attestation.official_release_id == record.id + && attestation.resource_root == record.resource_root + && attestation.publication_identity + == official_distribution_publication_identity(anchor) + && attestation.mapping_identity == anchor.mapping_identity + && attestation.manifest_identity == anchor.manifest_identity + && attestation.entry_count == anchor.entry_count + }); + if !identity_matches { + report.integrity_status = "invalid".to_string(); + report.status = "invalid".to_string(); + report.status_code = "distribution.attestation_invalid".to_string(); + report.ready = false; + report + .diagnostics + .push("attestation 与当前 publication generation 不一致".to_string()); + return Ok(report); + } + + if report.integrity_status == "verified" { + let fresh = report.verified_at.is_some_and(|verified_at| { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + now.saturating_sub(verified_at) <= report.max_age_seconds + }); + if !fresh { + report.integrity_status = "stale".to_string(); + report.status = "stale".to_string(); + report.status_code = "distribution.attestation_stale".to_string(); + report.ready = false; + report + .diagnostics + .push("当前官方 attestation 已超过 freshness window".to_string()); + return Ok(report); + } + if !attestation.ready { + report.integrity_status = "invalid".to_string(); + report.status = "invalid".to_string(); + report.status_code = "distribution.attestation_invalid".to_string(); + report.ready = false; + report + .diagnostics + .push("attestation integrity=verified 但 ready=false".to_string()); + return Ok(report); + } + report.status = "ready".to_string(); + report.status_code = "distribution.ready".to_string(); + report.ready = true; + } else { + report.ready = false; + } + Ok(report) +} + /// Lists one or both release namespaces. pub fn build_release_list( official_root: &Path, @@ -1793,6 +2027,77 @@ mod tests { assert!(!official_blocked.available); } + #[test] + fn lightweight_attestation_is_current_fresh_and_does_not_hash_artifacts() { + let temp = tempfile::tempdir().unwrap(); + let official_root = temp.path().join("official"); + let version = prepare_official(&official_root, "official-v1"); + fs::create_dir_all(&official_root).unwrap(); + symlink( + Path::new(OFFICIAL_VERSIONS_DIR).join("official-v1"), + official_root.join(OFFICIAL_CURRENT_LINK), + ) + .unwrap(); + fs::write( + official_root.join(OFFICIAL_VERSION_STATE_FILE), + serde_json::to_vec(&OfficialVersionState { + current_completed_version: Some(official_record(&official_root, "official-v1")), + ..OfficialVersionState::default() + }) + .unwrap(), + ) + .unwrap(); + + crate::official_download::write_official_distribution_attestation_at( + &version, + "official-v1", + "verified", + Vec::new(), + ) + .unwrap(); + let healthy = build_official_distribution_attestation(&official_root).unwrap(); + assert!(healthy.ready); + assert_eq!(healthy.integrity_status, "verified"); + assert_eq!(healthy.release_id, "official-v1"); + assert!(healthy.verification_generation > 0); + + // The lightweight read path only rechecks the publication anchor and + // manifest bytes; artifact damage is recorded by explicit verify. + fs::write(version.join("other.bin"), b"tampered").unwrap(); + let still_authorized = build_official_distribution_attestation(&official_root).unwrap(); + assert!(still_authorized.ready); + + crate::official_download::write_official_distribution_attestation_at( + &version, + "official-v1", + "invalid", + vec!["other.bin: size_or_hash_mismatch".to_string()], + ) + .unwrap(); + let invalid = build_official_distribution_attestation(&official_root).unwrap(); + assert!(!invalid.ready); + assert_eq!(invalid.integrity_status, "invalid"); + + fs::write(version.join("other.bin"), b"official-other").unwrap(); + let mut fresh = + crate::official_download::read_official_distribution_attestation_at(&version) + .unwrap() + .unwrap(); + fresh.integrity_status = "verified".to_string(); + fresh.status = "ready".to_string(); + fresh.status_code = "distribution.ready".to_string(); + fresh.ready = true; + fresh.verified_at = Some(0); + fs::write( + version.join(crate::OFFICIAL_DISTRIBUTION_ATTESTATION_FILE), + serde_json::to_vec(&fresh).unwrap(), + ) + .unwrap(); + let stale = build_official_distribution_attestation(&official_root).unwrap(); + assert!(!stale.ready); + assert_eq!(stale.integrity_status, "stale"); + } + #[test] fn distribution_selection_does_not_fallback_from_damaged_localized_release() { let temp = tempfile::tempdir().unwrap(); diff --git a/internal/api/admin.go b/internal/api/admin.go index 3b6ece7..a405053 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -1332,7 +1332,7 @@ func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunPa func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryConfirmParams) error { if strings.TrimSpace(params.RecordID) == "" || strings.TrimSpace(params.Reviewer) == "" { - return errors.New("Translation Memory confirm requires record_id and reviewer") + return errors.New("translation memory confirm requires record_id and reviewer") } return nil } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index eb5ac30..c9edcb2 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -91,11 +91,17 @@ func fixtureRPCBackend(t *testing.T, root string) *fakeBackend { doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"}, catalog: catalog, manifest: &backendrpc.ResourceManifestPage{ - Available: true, - ResourceRoot: root, - ManifestVersion: 1, - TotalEntries: len(manifestEntries), - Entries: manifestEntries, + Available: true, + Channel: "official", + ReleaseID: "official-fixture", + ResourceRoot: root, + ManifestVersion: 1, + PublicationIdentity: "fixture-publication-v1", + MappingIdentity: "fixture-mapping-v1", + ManifestIdentity: "fixture-manifest-v1", + Generation: 1, + TotalEntries: len(manifestEntries), + Entries: manifestEntries, }, releaseStatus: &backendrpc.ReleaseStatusReport{ Status: "ready", @@ -821,13 +827,16 @@ type fakeBackend struct { statusCalls int doctorCalls int releaseStatusCalls int + attestationCalls int status *backendrpc.DaemonStatusReport doctor *backendrpc.DoctorReport releaseStatus *backendrpc.ReleaseStatusReport releaseStatusErr error + attestation *backendrpc.DistributionAttestation catalog json.RawMessage resource *backendrpc.ResourceState manifest *backendrpc.ResourceManifestPage + manifestParams []backendrpc.ResourceManifestParams daemonLogs *backendrpc.LogsReport taskList *backendrpc.TaskList taskStatus *backendrpc.TaskRecord @@ -858,6 +867,77 @@ func (f *fakeBackend) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseSta OfficialDistributionReady: true, }, nil } +func (f *fakeBackend) ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) { + f.attestationCalls++ + // Keep the older test fixture controls useful while discovery moves to the + // lightweight RPC: releaseStatus still supplies the desired ready/blocked + // state unless a test explicitly installs an attestation. + if f.attestation != nil { + return f.attestation, nil + } + f.releaseStatusCalls++ + if f.releaseStatusErr != nil { + return nil, f.releaseStatusErr + } + releaseStatus := f.releaseStatus + if releaseStatus == nil { + releaseStatus = &backendrpc.ReleaseStatusReport{ + Status: "ready", + StatusCode: "distribution.ready", + OfficialDistributionReady: true, + } + } + releaseID := "fixture-release" + resourceRoot := "" + if f.manifest != nil { + releaseID = f.manifest.ReleaseID + resourceRoot = f.manifest.ResourceRoot + } + var catalog struct { + Version struct { + ID string `json:"id"` + ResourceRoot string `json:"resource_root"` + } `json:"version"` + } + if json.Unmarshal(f.catalog, &catalog) == nil { + if catalog.Version.ID != "" { + releaseID = catalog.Version.ID + } + if catalog.Version.ResourceRoot != "" { + resourceRoot = catalog.Version.ResourceRoot + } + } + if releaseStatus.OfficialCurrentReleaseID != "" { + releaseID = releaseStatus.OfficialCurrentReleaseID + } + integrity := "verified" + for _, release := range releaseStatus.Releases { + if release.Channel == "official" && release.Current { + integrity = release.DistributionIntegrityStatus + break + } + } + return &backendrpc.DistributionAttestation{ + Available: true, + Channel: "official", + ReleaseID: releaseID, + ResourceRoot: resourceRoot, + PublicationIdentity: "fixture-publication-v1", + MappingIdentity: "fixture-mapping-v1", + ManifestIdentity: "fixture-manifest-v1", + EntryCount: func() int { + if f.manifest == nil { + return 0 + } + return len(f.manifest.Entries) + }(), + IntegrityStatus: integrity, + Status: releaseStatus.Status, + StatusCode: releaseStatus.StatusCode, + Ready: releaseStatus.OfficialDistributionReady, + VerificationGeneration: 1, + }, nil +} func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) { if f.resource != nil { return f.resource, nil @@ -867,8 +947,33 @@ func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceSt func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) { return f.catalog, nil } -func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { - return f.manifest, nil +func (f *fakeBackend) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) { + if f.manifest == nil { + return nil, nil + } + f.manifestParams = append(f.manifestParams, params) + page := *f.manifest + if page.ReleaseID == "" { + page.ReleaseID = params.ReleaseID + } + if page.ResourceRoot == "" { + page.ResourceRoot = f.manifest.ResourceRoot + } + if page.PublicationIdentity == "" { + page.PublicationIdentity = params.ExpectedPublicationIdentity + } + if page.ManifestIdentity == "" { + page.ManifestIdentity = params.ExpectedManifestIdentity + } + if page.MappingIdentity == "" { + page.MappingIdentity = "fixture-mapping-v1" + } + if page.Generation == 0 { + page.Generation = 1 + } + page.Offset = params.Offset + page.Limit = params.Limit + return &page, nil } func (f *fakeBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) { if f.daemonLogs != nil { @@ -1762,9 +1867,12 @@ func (p *pollingBackend) ResourceState(ctx context.Context) (*backendrpc.Resourc func (p *pollingBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) { return nil, errors.New("unexpected catalog call") } -func (p *pollingBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { +func (p *pollingBackend) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) { return nil, errors.New("unexpected manifest call") } +func (p *pollingBackend) ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) { + return nil, errors.New("unexpected attestation call") +} func TestStartRefreshLoopPollsBackend(t *testing.T) { cfg := DefaultConfig() diff --git a/internal/api/config.go b/internal/api/config.go index bc00cc5..7578996 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -332,7 +332,8 @@ BAT_API_STATE_DIR=/tmp/bat-pid # BAT_API_SOCKET=/tmp/bat-pid/bat.sock # Optional override of the published release root (fixtures / emergency only). -# Production obtains resource_root from daemon RPC (catalog.status / resource.manifest). +# Production obtains resource_root from daemon RPC (release.attestation + +# generation-bound resource.manifest). # BAT_API_RESOURCE_ROOT= # Optional server-info JSON for Addressables root rewrite diff --git a/internal/api/contract_fixture_test.go b/internal/api/contract_fixture_test.go index b36dcd0..427e0b9 100644 --- a/internal/api/contract_fixture_test.go +++ b/internal/api/contract_fixture_test.go @@ -24,12 +24,14 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) { availableRaw := readContractFixture(t, "catalog-status.available.json") unavailableRaw := readContractFixture(t, "catalog-status.unavailable.json") manifestRaw := readContractFixture(t, "resource-manifest.page0.json") + attestationRaw := readContractFixture(t, "release-attestation.json") snapshotRaw := readContractFixture(t, "official-sync-snapshot.json") for name, raw := range map[string][]byte{ "catalog available": availableRaw, "catalog unavailable": unavailableRaw, "resource manifest": manifestRaw, + "attestation": attestationRaw, "snapshot": snapshotRaw, } { if bytes.Contains(raw, []byte("/tmp/")) { @@ -71,7 +73,12 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) { if err := json.Unmarshal(manifestRaw, &manifest); err != nil { t.Fatalf("decode resource manifest: %v", err) } - if !manifest.Available || manifest.ManifestVersion != 1 || manifest.TotalEntries != 2 { + if !manifest.Available || manifest.Channel != "official" || + manifest.ManifestVersion != 1 || manifest.TotalEntries != 2 || + manifest.ReleaseID != "${VERSION_ID}" || + manifest.PublicationIdentity != "${PUBLICATION_IDENTITY}" || + manifest.ManifestIdentity != "${MANIFEST_IDENTITY}" || + manifest.Generation != 7 { t.Fatalf("manifest header=%+v", manifest) } if len(manifest.Entries) != 2 { @@ -84,6 +91,18 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) { t.Fatalf("manifest first entry=%+v", manifest.Entries[0]) } + var attestation backendrpc.DistributionAttestation + if err := json.Unmarshal(attestationRaw, &attestation); err != nil { + t.Fatalf("decode attestation: %v", err) + } + if !attestation.Available || !attestation.Ready || + attestation.ReleaseID != "${VERSION_ID}" || + attestation.ManifestIdentity != "${MANIFEST_IDENTITY}" || + attestation.VerificationGeneration != 7 || + attestation.VerifiedAt == nil || *attestation.VerifiedAt != 1000 { + t.Fatalf("attestation=%+v", attestation) + } + var snapshot struct { AppVersion string `json:"app_version"` BundleVersion string `json:"bundle_version"` diff --git a/internal/api/openapi.go b/internal/api/openapi.go index a442c69..a7a0d06 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -27,9 +27,9 @@ paths: summary: Release readiness responses: "200": - description: A release authorized by Rust release.status and fully represented by the local read snapshot is available. + description: A current official release authorized by Rust release.attestation and fully represented by the bound local read snapshot is available. "503": - description: The Rust whole-release distribution health fact or the local read snapshot is not distributable. + description: The Rust current attestation is unavailable, stale, invalid, or the bound local read snapshot is not distributable. /v1/bootstrap: get: summary: Startup resource bootstrap diff --git a/internal/api/release_attestation_test.go b/internal/api/release_attestation_test.go new file mode 100644 index 0000000..32dece4 --- /dev/null +++ b/internal/api/release_attestation_test.go @@ -0,0 +1,241 @@ +package api + +import ( + "context" + "encoding/json" + "testing" + + "bat-api/internal/backendrpc" +) + +func testAttestation(root, releaseID, integrity string, ready bool) *backendrpc.DistributionAttestation { + return &backendrpc.DistributionAttestation{ + Available: true, + Channel: "official", + ReleaseID: releaseID, + ResourceRoot: root, + PublicationIdentity: "publication-" + releaseID, + MappingIdentity: "mapping-" + releaseID, + ManifestIdentity: "manifest-" + releaseID, + EntryCount: 3, + IntegrityStatus: integrity, + Status: integrity, + StatusCode: "distribution." + integrity, + Ready: ready, + VerificationGeneration: 4, + } +} + +func testManifestPage(attestation *backendrpc.DistributionAttestation, offset int, entries int) *backendrpc.ResourceManifestPage { + pageEntries := make([]backendrpc.ResourceManifestEntry, entries) + for index := range pageEntries { + size := uint64(index + 1) + pageEntries[index] = backendrpc.ResourceManifestEntry{ + URL: "https://example.invalid/" + string(rune('a'+offset+index)), + Destination: "resource-" + string(rune('a'+offset+index)), + Bytes: &size, + BLAKE3: "blake3", + } + } + return &backendrpc.ResourceManifestPage{ + Available: true, + Channel: "official", + ReleaseID: attestation.ReleaseID, + ResourceRoot: attestation.ResourceRoot, + ManifestVersion: 1, + PublicationIdentity: attestation.PublicationIdentity, + MappingIdentity: attestation.MappingIdentity, + ManifestIdentity: attestation.ManifestIdentity, + Generation: attestation.VerificationGeneration, + TotalEntries: attestation.EntryCount, + Offset: offset, + Limit: 2, + Entries: pageEntries, + } +} + +type pagedManifestBackend struct { + *fakeBackend + pages []*backendrpc.ResourceManifestPage + params []backendrpc.ResourceManifestParams +} + +func (b *pagedManifestBackend) ResourceManifest(_ context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) { + b.params = append(b.params, params) + pageIndex := len(b.params) - 1 + page := *b.pages[pageIndex] + return &page, nil +} + +func TestFetchAllManifestEntriesRejectsMixedPages(t *testing.T) { + attestation := testAttestation("/srv/official/current", "official-a", "verified", true) + tests := []struct { + name string + mutate func(*backendrpc.ResourceManifestPage) + }{ + { + name: "release", + mutate: func(page *backendrpc.ResourceManifestPage) { page.ReleaseID = "official-b" }, + }, + { + name: "root", + mutate: func(page *backendrpc.ResourceManifestPage) { page.ResourceRoot = "/srv/official/current-b" }, + }, + { + name: "manifest identity", + mutate: func(page *backendrpc.ResourceManifestPage) { page.ManifestIdentity = "manifest-b" }, + }, + { + name: "total", + mutate: func(page *backendrpc.ResourceManifestPage) { page.TotalEntries = 4 }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + first := testManifestPage(attestation, 0, 2) + second := testManifestPage(attestation, 2, 1) + test.mutate(second) + backend := &pagedManifestBackend{ + fakeBackend: &fakeBackend{}, + pages: []*backendrpc.ResourceManifestPage{first, second}, + } + if _, _, _, err := fetchAllManifestEntriesWithPageSize( + context.Background(), + backend, + attestation, + 2, + ); err == nil { + t.Fatal("expected mixed-page validation error") + } + }) + } +} + +func TestFetchAllManifestEntriesAcceptsMatchingGeneration(t *testing.T) { + attestation := testAttestation("/srv/official/current", "official-a", "verified", true) + backend := &pagedManifestBackend{ + fakeBackend: &fakeBackend{}, + pages: []*backendrpc.ResourceManifestPage{ + testManifestPage(attestation, 0, 2), + testManifestPage(attestation, 2, 1), + }, + } + entries, version, root, err := fetchAllManifestEntriesWithPageSize( + context.Background(), + backend, + attestation, + 2, + ) + if err != nil { + t.Fatal(err) + } + if len(entries) != 3 || version != 1 || root != attestation.ResourceRoot { + t.Fatalf("entries=%d version=%d root=%q", len(entries), version, root) + } + if len(backend.params) != 2 || + backend.params[1].ReleaseID != attestation.ReleaseID || + backend.params[1].ExpectedManifestIdentity != attestation.ManifestIdentity { + t.Fatalf("params=%+v", backend.params) + } +} + +func TestDiscoverRejectsAttestationThenCatalogCurrentSwitch(t *testing.T) { + root := fixtureRoot(t) + backend := fixtureRPCBackend(t, root) + backend.attestation = testAttestation(root, "official-a", "verified", true) + catalog, err := json.Marshal(map[string]any{ + "available": true, + "version": map[string]any{ + "id": "official-b", + "resource_root": root, + }, + }) + if err != nil { + t.Fatal(err) + } + backend.catalog = catalog + result, err := DiscoverAndIndex(context.Background(), backend, "") + if err != nil { + t.Fatal(err) + } + if result.Index == nil || result.Index.Summary().Ready || result.Index.Summary().EntryCount != 0 { + t.Fatalf("summary=%+v", result.Index.Summary()) + } + if len(backend.manifestParams) != 0 { + t.Fatalf("manifest should not be fetched after current switch: %+v", backend.manifestParams) + } +} + +func TestHealthyAttestationThenCurrentSwitchClearsSnapshot(t *testing.T) { + root := copyFixtureRoot(t) + backend := fixtureRPCBackend(t, root) + backend.attestation = testAttestation(root, "official-a", "verified", true) + backend.attestation.EntryCount = 2 + backend.catalog = mustCatalogForTest(t, root, "official-a") + backend.manifest.ReleaseID = "official-a" + backend.manifest.PublicationIdentity = "publication-official-a" + backend.manifest.MappingIdentity = "mapping-official-a" + backend.manifest.ManifestIdentity = "manifest-official-a" + backend.manifest.Generation = 4 + cfg := DefaultConfig() + cfg.RefreshInterval = 0 + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + server := NewServer(cfg, backend, nil) + if err := server.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + if !server.index().Summary().Ready { + t.Fatal("initial snapshot should be ready") + } + + backend.attestation = testAttestation(root, "official-b", "verified", true) + backend.catalog = mustCatalogForTest(t, root, "official-b") + if err := server.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + if summary := server.index().Summary(); summary.Ready || summary.ResourceRoot != "" { + t.Fatalf("mixed snapshot was retained: %+v", summary) + } +} + +func mustCatalogForTest(t *testing.T, root, releaseID string) json.RawMessage { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "available": true, + "version": map[string]any{ + "id": releaseID, + "resource_root": root, + }, + }) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestStaleOrInvalidMatchingAttestationNeverReadiesIndex(t *testing.T) { + for _, integrity := range []string{"stale", "invalid"} { + t.Run(integrity, func(t *testing.T) { + root := fixtureRoot(t) + backend := fixtureRPCBackend(t, root) + backend.attestation = testAttestation(root, "official-a", integrity, false) + backend.attestation.EntryCount = 2 + backend.catalog = mustCatalogForTest(t, root, "official-a") + backend.manifest.ReleaseID = "official-a" + backend.manifest.PublicationIdentity = "publication-official-a" + backend.manifest.MappingIdentity = "mapping-official-a" + backend.manifest.ManifestIdentity = "manifest-official-a" + backend.manifest.Generation = 4 + result, err := DiscoverAndIndex(context.Background(), backend, "") + if err != nil { + t.Fatal(err) + } + if result.Index == nil || result.Index.Summary().Ready || + result.Index.Summary().Distribution.Ready { + t.Fatalf("summary=%+v", result.Index.Summary()) + } + }) + } +} diff --git a/internal/api/release_index.go b/internal/api/release_index.go index 12feded..fb40270 100644 --- a/internal/api/release_index.go +++ b/internal/api/release_index.go @@ -57,17 +57,25 @@ type GameMainConfigSummary struct { // DistributionHealth is the release-level authorization used by read paths. // -// In RPC mode Ready is copied from Rust's release.status -// official_distribution_ready fact. The local manifest checks only establish -// that this process has a usable read snapshot; they do not replace Rust's -// release verifier. +// In RPC mode these fields are copied from Rust's current official +// attestation. The local manifest checks only establish that this process has +// a complete, safe read snapshot; they do not replace Rust's verifier. type DistributionHealth struct { - Ready bool `json:"ready"` - Source string `json:"source"` - Status string `json:"status,omitempty"` - StatusCode string `json:"status_code,omitempty"` - IntegrityStatus string `json:"integrity_status,omitempty"` - Diagnostics []string `json:"diagnostics,omitempty"` + Ready bool `json:"ready"` + Source string `json:"source"` + Channel string `json:"channel,omitempty"` + ReleaseID string `json:"release_id,omitempty"` + ResourceRoot string `json:"resource_root,omitempty"` + PublicationIdentity string `json:"publication_identity,omitempty"` + MappingIdentity string `json:"mapping_identity,omitempty"` + ManifestIdentity string `json:"manifest_identity,omitempty"` + EntryCount int `json:"entry_count,omitempty"` + VerificationGeneration uint64 `json:"verification_generation,omitempty"` + VerifiedAt *uint64 `json:"verified_at,omitempty"` + Status string `json:"status,omitempty"` + StatusCode string `json:"status_code,omitempty"` + IntegrityStatus string `json:"integrity_status,omitempty"` + Diagnostics []string `json:"diagnostics,omitempty"` } // ReleaseIndex is the in-memory view of a published resource root. @@ -122,7 +130,11 @@ func (idx *ReleaseIndex) Summary() ReleaseSummary { if distribution.Source == "" { distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest" } else { - distribution.Ready = distribution.Ready && localComplete + distribution.Ready = distribution.Ready && + localComplete && + (idx.Source != "rpc" || + (distribution.ManifestIdentity != "" && + distribution.EntryCount == len(idx.Entries))) } return ReleaseSummary{ ResourceRoot: idx.ResourceRoot, diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index 9ad5811..e53c347 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -12,16 +12,18 @@ import ( // Backend is the subset of daemon RPC used by bat-api. // -// Call order for discovery (per plan review): +// Call order for discovery: // 1. daemon.status // 2. daemon.doctor -// 3. catalog.status / resource.manifest (and resource.state as needed) +// 3. release.attestation +// 4. catalog.status / bound resource.manifest (and resource.state as needed) type Backend interface { DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) CatalogStatus(ctx context.Context) (json.RawMessage, error) - ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) + ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) + ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) } // ControlBackend is the explicitly allowlisted mutation subset exposed through @@ -146,8 +148,11 @@ func (r RPCClient) ResourceState(ctx context.Context) (*backendrpc.ResourceState func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) { return r.Client.CatalogStatus(ctx) } -func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { - return r.Client.ResourceManifest(ctx, offset, limit) +func (r RPCClient) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) { + return r.Client.ResourceManifest(ctx, params) +} +func (r RPCClient) ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) { + return r.Client.ReleaseAttestation(ctx) } func (r RPCClient) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) { return r.Client.DaemonRestart(ctx) @@ -310,6 +315,7 @@ type DiscoverResult struct { DoctorHealthy *bool Status *backendrpc.DaemonStatusReport Doctor *backendrpc.DoctorReport + Attestation *backendrpc.DistributionAttestation ReleaseStatus *backendrpc.ReleaseStatusReport Distribution DistributionHealth Snapshot *SnapshotSummary @@ -318,10 +324,11 @@ type DiscoverResult struct { Warnings []string } -// DiscoverAndIndex contacts the daemon (status first, then doctor, then -// release.status) and builds a release index from paginated resource.manifest -// plus on-disk checks. Rust's release.status is the only release-level -// integrity authorization used for the production RPC path. +// DiscoverAndIndex contacts the daemon (status first, then doctor, then the +// lightweight current-release attestation) and builds a release index from +// pages bound to that attestation plus on-disk checks. Rust's attestation is +// the only release-level integrity authorization used for the production RPC +// path; release.status remains an administrative diagnostic. // // If resourceRootOverride is non-empty, it wins over RPC-reported roots after // RPC health probes (still preferred for production to call status/doctor). @@ -390,30 +397,33 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride return out, nil } - // 3) release.status is the Rust-owned whole-release distribution gate. - releaseStatusBackend, ok := backend.(ReleaseStatusBackend) + // 3) release.attestation is the Rust-owned current-release distribution gate. + attestationBackend, ok := backend.(AttestationBackend) if !ok { - out.Warnings = append(out.Warnings, "release.status: backend does not expose Rust release health") + out.Warnings = append(out.Warnings, "release.attestation: backend does not expose Rust health proof") return emptyRPCResult(out, nil, "Rust release health is unavailable"), fmt.Errorf("rust release health is unavailable") } - releaseStatus, err := releaseStatusBackend.ReleaseStatus(ctx) + attestation, err := attestationBackend.ReleaseAttestation(ctx) if err != nil { - out.Warnings = append(out.Warnings, fmt.Sprintf("release.status: %v", err)) + out.Warnings = append(out.Warnings, fmt.Sprintf("release.attestation: %v", err)) return emptyRPCResult(out, nil, "Rust release health query failed"), - fmt.Errorf("release.status failed: %w", err) + fmt.Errorf("release.attestation failed: %w", err) } - if releaseStatus == nil { - out.Warnings = append(out.Warnings, "release.status: empty response") + if attestation == nil { + out.Warnings = append(out.Warnings, "release.attestation: empty response") return emptyRPCResult(out, nil, "Rust release health query returned no response"), - fmt.Errorf("release.status returned an empty response") + fmt.Errorf("release.attestation returned an empty response") + } + out.Attestation = attestation + out.Distribution = rustAttestationHealth(attestation) + if !attestation.Available { + return emptyRPCResult(out, nil, "Rust current official distribution attestation is unavailable"), nil } - out.ReleaseStatus = releaseStatus - out.Distribution = rustDistributionHealth(releaseStatus) // Catalog / resource discovery var snapshot *SnapshotSummary - var resourceRoot string + resourceRoot := attestation.ResourceRoot catalogAvailabilityKnown := false catalogAvailable := false @@ -429,18 +439,31 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride } if snapshot != nil && snapshot.VersionID != "" && - out.ReleaseStatus.OfficialCurrentReleaseID != "" && - snapshot.VersionID != out.ReleaseStatus.OfficialCurrentReleaseID { + attestation.ReleaseID != "" && + snapshot.VersionID != attestation.ReleaseID { return emptyRPCResult( out, snapshot, fmt.Sprintf( - "release.status current ID %q does not match catalog current ID %q", - out.ReleaseStatus.OfficialCurrentReleaseID, + "release.attestation current ID %q does not match catalog current ID %q", + attestation.ReleaseID, snapshot.VersionID, ), ), nil } + if resourceRoot != "" && + attestation.ResourceRoot != "" && + resourceRoot != attestation.ResourceRoot { + return emptyRPCResult( + out, + snapshot, + fmt.Sprintf( + "catalog current root %q does not match attestation root %q", + resourceRoot, + attestation.ResourceRoot, + ), + ), nil + } if catalogAvailabilityKnown && !catalogAvailable { return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil } @@ -485,7 +508,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride } } - entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend) + entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend, attestation) if err != nil { out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err)) // Without the RPC manifest there is no evidence that the local @@ -498,6 +521,17 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride ), nil } if rootFromManifest != "" { + if attestation.ResourceRoot != "" && rootFromManifest != attestation.ResourceRoot { + return emptyRPCResult( + out, + snapshot, + fmt.Sprintf( + "resource.manifest resource root %q does not match attestation root %q", + rootFromManifest, + attestation.ResourceRoot, + ), + ), nil + } resourceRoot = rootFromManifest } if resourceRootOverride != "" { @@ -530,6 +564,13 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride return out, nil } +// AttestationBackend exposes the lightweight current official health proof. +// It is intentionally separate from ReleaseStatusBackend because health +// refreshes must not require the historical release scan. +type AttestationBackend interface { + ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) +} + func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult { distribution := out.Distribution if distribution.Source == "" { @@ -553,52 +594,48 @@ func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning stri return out } -func rustDistributionHealth(report *backendrpc.ReleaseStatusReport) DistributionHealth { +func rustAttestationHealth(report *backendrpc.DistributionAttestation) DistributionHealth { health := DistributionHealth{ - Source: "rust_release_status", - Status: "blocked", - StatusCode: "distribution.blocked", - IntegrityStatus: "unknown", + Source: "rust_release_attestation", + Status: "unavailable", + StatusCode: "distribution.attestation_unavailable", + IntegrityStatus: "unavailable", } if report == nil { return health } - health.Ready = report.OfficialDistributionReady + health.Ready = report.Ready + health.Channel = report.Channel + health.ReleaseID = report.ReleaseID + health.ResourceRoot = report.ResourceRoot + health.PublicationIdentity = report.PublicationIdentity + health.MappingIdentity = report.MappingIdentity + health.ManifestIdentity = report.ManifestIdentity + health.EntryCount = report.EntryCount + health.VerificationGeneration = report.VerificationGeneration + health.VerifiedAt = report.VerifiedAt health.Status = report.Status health.StatusCode = report.StatusCode + health.IntegrityStatus = report.IntegrityStatus + health.Diagnostics = append([]string(nil), report.Diagnostics...) if health.Status == "" { - if health.Ready { - health.Status = "ready" - } else { - health.Status = "blocked" - } + health.Status = "unavailable" } if health.StatusCode == "" { - if health.Ready { - health.StatusCode = "distribution.ready" - } else { - health.StatusCode = "distribution.blocked" - } + health.StatusCode = "distribution.attestation_unavailable" } - for _, release := range report.Releases { - if release.Channel == "official" && release.Current { - health.IntegrityStatus = release.DistributionIntegrityStatus - health.Diagnostics = append([]string(nil), release.Diagnostics...) - break - } - } - if health.IntegrityStatus == "valid" && !health.Ready { - health.IntegrityStatus = "invalid" + if health.IntegrityStatus == "" { + health.IntegrityStatus = "unavailable" } return health } func unavailableRustDistributionHealth() DistributionHealth { return DistributionHealth{ - Source: "rust_release_status", + Source: "rust_release_attestation", Status: "unavailable", - StatusCode: "distribution.health_unavailable", - IntegrityStatus: "unknown", + StatusCode: "distribution.attestation_unavailable", + IntegrityStatus: "unavailable", } } @@ -675,25 +712,85 @@ func parseCatalogAvailability(raw json.RawMessage) (bool, bool) { return *payload.Available, true } -func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEntry, int, string, error) { - const pageSize = 500 +func fetchAllManifestEntries( + ctx context.Context, + backend Backend, + attestation *backendrpc.DistributionAttestation, +) ([]manifestEntry, int, string, error) { + return fetchAllManifestEntriesWithPageSize(ctx, backend, attestation, 500) +} + +func fetchAllManifestEntriesWithPageSize( + ctx context.Context, + backend Backend, + attestation *backendrpc.DistributionAttestation, + pageSize int, +) ([]manifestEntry, int, string, error) { + if pageSize <= 0 { + return nil, 0, "", fmt.Errorf("manifest page size must be positive") + } offset := 0 var all []manifestEntry var version int - var root string + var root, releaseID, publicationIdentity, mappingIdentity, manifestIdentity string + total := -1 for { - page, err := backend.ResourceManifest(ctx, offset, pageSize) + page, err := backend.ResourceManifest(ctx, backendrpc.ResourceManifestParams{ + ReleaseID: attestation.ReleaseID, + ExpectedPublicationIdentity: attestation.PublicationIdentity, + ExpectedManifestIdentity: attestation.ManifestIdentity, + Offset: offset, + Limit: pageSize, + }) if err != nil { return nil, 0, "", err } + if page == nil { + return nil, 0, "", fmt.Errorf("resource.manifest returned nil page") + } if !page.Available { return nil, 0, "", fmt.Errorf("resource.manifest available=false") } if root == "" { root = page.ResourceRoot - } - if version == 0 { + releaseID = page.ReleaseID + publicationIdentity = page.PublicationIdentity + mappingIdentity = page.MappingIdentity + manifestIdentity = page.ManifestIdentity version = page.ManifestVersion + total = page.TotalEntries + } else if page.ResourceRoot != root || + page.ReleaseID != releaseID || + page.PublicationIdentity != publicationIdentity || + page.MappingIdentity != mappingIdentity || + page.ManifestIdentity != manifestIdentity || + page.ManifestVersion != version || + page.TotalEntries != total { + return nil, 0, "", fmt.Errorf("resource.manifest page identity or total changed") + } + if page.Offset != offset { + return nil, 0, "", fmt.Errorf( + "resource.manifest page offset mismatch: requested=%d actual=%d", + offset, + page.Offset, + ) + } + if page.Limit <= 0 || page.Limit > pageSize { + return nil, 0, "", fmt.Errorf("resource.manifest page limit is unreasonable: %d", page.Limit) + } + if page.TotalEntries < 0 || len(page.Entries) > page.Limit { + return nil, 0, "", fmt.Errorf("resource.manifest page entry count is unreasonable") + } + if total < 0 || offset > total || offset+len(page.Entries) > total { + return nil, 0, "", fmt.Errorf("resource.manifest page exceeds declared total") + } + if attestation != nil && (releaseID != attestation.ReleaseID || + root != attestation.ResourceRoot || + publicationIdentity != attestation.PublicationIdentity || + mappingIdentity != attestation.MappingIdentity || + manifestIdentity != attestation.ManifestIdentity || + total != attestation.EntryCount) { + return nil, 0, "", fmt.Errorf("resource.manifest page does not match attestation") } for _, e := range page.Entries { var bytes uint64 @@ -708,9 +805,12 @@ func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEn }) } offset += len(page.Entries) - if len(page.Entries) == 0 || offset >= page.TotalEntries { + if offset == total { break } + if len(page.Entries) == 0 || len(page.Entries) < page.Limit { + return nil, 0, "", fmt.Errorf("resource.manifest page has a gap before total") + } } return all, version, root, nil } diff --git a/internal/api/testdata/contract/README.md b/internal/api/testdata/contract/README.md index 8d8fba0..e43391d 100644 --- a/internal/api/testdata/contract/README.md +++ b/internal/api/testdata/contract/README.md @@ -7,7 +7,9 @@ contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本 覆盖范围: - `catalog.status` 可用与不可用响应。 -- `resource.manifest` 第一页分页响应。 +- `resource.manifest` 第一页分页响应,包含 release/publication/mapping/manifest + identity 和 generation 绑定字段。 +- `release.attestation` 当前 official health/publication proof。 - 对应 release 的 `official-sync-snapshot.json`。 - `launcher_metadata` 与 `game_main_config_bootstrap` 的 Go mirror 解码。 - Rust Glossary V1 query 响应,覆盖 alias、approved review、source provenance 和完整 history。 diff --git a/internal/api/testdata/contract/release-attestation.json b/internal/api/testdata/contract/release-attestation.json new file mode 100644 index 0000000..617aa04 --- /dev/null +++ b/internal/api/testdata/contract/release-attestation.json @@ -0,0 +1,18 @@ +{ + "available": true, + "channel": "official", + "diagnostics": [], + "entry_count": 2, + "integrity_status": "verified", + "manifest_identity": "${MANIFEST_IDENTITY}", + "mapping_identity": "${MAPPING_IDENTITY}", + "max_age_seconds": 900, + "publication_identity": "${PUBLICATION_IDENTITY}", + "ready": true, + "release_id": "${VERSION_ID}", + "resource_root": "${RESOURCE_ROOT}", + "status": "ready", + "status_code": "distribution.ready", + "verification_generation": 7, + "verified_at": 1000 +} diff --git a/internal/api/testdata/contract/resource-manifest.page0.json b/internal/api/testdata/contract/resource-manifest.page0.json index 6cac3ee..0505dbd 100644 --- a/internal/api/testdata/contract/resource-manifest.page0.json +++ b/internal/api/testdata/contract/resource-manifest.page0.json @@ -1,5 +1,6 @@ { "available": true, + "channel": "official", "entries": [ { "blake3": "0000000000000000000000000000000000000000000000000000000000000000", @@ -14,9 +15,14 @@ "url": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.hash" } ], + "generation": 7, "limit": 2, + "manifest_identity": "${MANIFEST_IDENTITY}", "manifest_version": 1, + "mapping_identity": "${MAPPING_IDENTITY}", "offset": 0, + "publication_identity": "${PUBLICATION_IDENTITY}", + "release_id": "${VERSION_ID}", "resource_root": "${RESOURCE_ROOT}", "total_entries": 2 } diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index 4388ec9..5254959 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -118,7 +118,7 @@ func (c *Client) callEnvelope(ctx context.Context, method string, params any) (* if err != nil { return nil, err } - defer conn.Close() + defer func() { _ = conn.Close() }() if deadline, ok := c.deadline(ctx); ok { _ = conn.SetDeadline(deadline) @@ -877,14 +877,29 @@ type ResourceManifestEntry struct { BLAKE3 string `json:"blake3,omitempty"` } +// ResourceManifestParams binds every page to one attested official release. +type ResourceManifestParams struct { + ReleaseID string `json:"release_id,omitempty"` + ExpectedPublicationIdentity string `json:"expected_publication_identity,omitempty"` + ExpectedManifestIdentity string `json:"expected_manifest_identity,omitempty"` + Offset int `json:"offset"` + Limit int `json:"limit"` +} + type ResourceManifestPage struct { - Available bool `json:"available"` - ResourceRoot string `json:"resource_root,omitempty"` - ManifestVersion int `json:"manifest_version,omitempty"` - TotalEntries int `json:"total_entries,omitempty"` - Offset int `json:"offset,omitempty"` - Limit int `json:"limit,omitempty"` - Entries []ResourceManifestEntry `json:"entries,omitempty"` + Available bool `json:"available"` + Channel string `json:"channel,omitempty"` + ReleaseID string `json:"release_id,omitempty"` + ResourceRoot string `json:"resource_root,omitempty"` + ManifestVersion int `json:"manifest_version,omitempty"` + PublicationIdentity string `json:"publication_identity,omitempty"` + MappingIdentity string `json:"mapping_identity,omitempty"` + ManifestIdentity string `json:"manifest_identity,omitempty"` + Generation uint64 `json:"generation,omitempty"` + TotalEntries int `json:"total_entries,omitempty"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` + Entries []ResourceManifestEntry `json:"entries,omitempty"` } func (c *Client) DaemonStatus(ctx context.Context) (*DaemonStatusReport, error) { @@ -953,9 +968,35 @@ func (c *Client) ResourceRepair(ctx context.Context) (*TaskAccepted, error) { return &out, err } -func (c *Client) ResourceManifest(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) { +// DistributionAttestation is the Rust-owned current official health proof. +type DistributionAttestation struct { + Available bool `json:"available"` + Channel string `json:"channel"` + ReleaseID string `json:"release_id"` + ResourceRoot string `json:"resource_root"` + PublicationIdentity string `json:"publication_identity"` + MappingIdentity string `json:"mapping_identity"` + ManifestIdentity string `json:"manifest_identity"` + EntryCount int `json:"entry_count"` + IntegrityStatus string `json:"integrity_status"` + Status string `json:"status"` + StatusCode string `json:"status_code"` + Ready bool `json:"ready"` + VerificationGeneration uint64 `json:"verification_generation"` + VerifiedAt *uint64 `json:"verified_at,omitempty"` + MaxAgeSeconds uint64 `json:"max_age_seconds"` + Diagnostics []string `json:"diagnostics,omitempty"` +} + +func (c *Client) ReleaseAttestation(ctx context.Context) (*DistributionAttestation, error) { + var out DistributionAttestation + _, err := c.Call(ctx, "release.attestation", nil, &out) + return &out, err +} + +func (c *Client) ResourceManifest(ctx context.Context, params ResourceManifestParams) (*ResourceManifestPage, error) { var out ResourceManifestPage - _, err := c.Call(ctx, "resource.manifest", pageParam{Offset: offset, Limit: limit}, &out) + _, err := c.Call(ctx, "resource.manifest", params, &out) return &out, err } diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index eec3443..5a1eb8c 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -249,6 +249,101 @@ func TestParseTextUnitsSendsQuery(t *testing.T) { } } +func TestReleaseAttestationMirrorsCurrentOfficialHealth(t *testing.T) { + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + if req.Method != "release.attestation" { + t.Fatalf("method = %s", req.Method) + } + return testResponse{ + Result: testEnvelope{ + OK: true, + Status: "ok", + Data: map[string]any{ + "available": true, + "channel": "official", + "release_id": "official-a", + "resource_root": "/srv/official/versions/official-a", + "publication_identity": "odp-v1-publication-a", + "mapping_identity": "odm-v1-mapping-a", + "manifest_identity": "manifest-a", + "entry_count": 2, + "integrity_status": "verified", + "status": "ready", + "status_code": "distribution.ready", + "ready": true, + "verification_generation": 7, + "verified_at": 1234, + "max_age_seconds": 900, + }, + }, + } + }) + + report, err := client.ReleaseAttestation(context.Background()) + if err != nil { + t.Fatalf("ReleaseAttestation error: %v", err) + } + if !report.Ready || report.ReleaseID != "official-a" || + report.ManifestIdentity != "manifest-a" || + report.VerificationGeneration != 7 { + t.Fatalf("report = %#v", report) + } +} + +func TestResourceManifestSendsAttestedGenerationParams(t *testing.T) { + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + if req.Method != "resource.manifest" { + t.Fatalf("method = %s", req.Method) + } + var params ResourceManifestParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decode params: %v", err) + } + if params.ReleaseID != "official-a" || + params.ExpectedPublicationIdentity != "odp-v1-publication-a" || + params.ExpectedManifestIdentity != "manifest-a" || + params.Offset != 1 || params.Limit != 100 { + t.Fatalf("params = %#v", params) + } + return testResponse{ + Result: testEnvelope{ + OK: true, + Status: "ok", + Data: map[string]any{ + "available": true, + "channel": "official", + "release_id": "official-a", + "resource_root": "/srv/official/versions/official-a", + "manifest_version": 1, + "publication_identity": "odp-v1-publication-a", + "mapping_identity": "odm-v1-mapping-a", + "manifest_identity": "manifest-a", + "generation": 7, + "total_entries": 2, + "offset": 1, + "limit": 100, + "entries": []any{}, + }, + }, + } + }) + + page, err := client.ResourceManifest(context.Background(), ResourceManifestParams{ + ReleaseID: "official-a", + ExpectedPublicationIdentity: "odp-v1-publication-a", + ExpectedManifestIdentity: "manifest-a", + Offset: 1, + Limit: 100, + }) + if err != nil { + t.Fatalf("ResourceManifest error: %v", err) + } + if page.ReleaseID != "official-a" || page.ManifestIdentity != "manifest-a" || + page.Generation != 7 { + t.Fatalf("page = %#v", page) + } +} + func TestUnityFSPatchFieldSendsTaggedReplacement(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "unityfs.patch_field" { diff --git a/scripts/check-doc-status.sh b/scripts/check-doc-status.sh index f7dd70d..d82b465 100644 --- a/scripts/check-doc-status.sh +++ b/scripts/check-doc-status.sh @@ -142,15 +142,20 @@ require_contains "docs/reference/rpc-backend-api.md" "daemon.restart" require_contains "docs/reference/rpc-backend-api.md" "daemon.clean-stable" require_contains "docs/reference/rpc-backend-api.md" "localized_release_status" require_contains "docs/reference/rpc-backend-api.md" "official_distribution_ready" -require_contains "docs/architecture/resource-release-layout.md" "release.status" +require_contains "docs/reference/rpc-backend-api.md" "release.attestation" +require_contains "docs/reference/rpc-backend-api.md" "expected_manifest_identity" +require_contains "docs/architecture/resource-release-layout.md" "release.attestation" require_contains "Makefile" "check-docs:" require_contains "Makefile" "format: fmt" require_contains "Makefile" "ci-check:" require_contains "Makefile" "ci: ci-check" require_file "scripts/ci-check.sh" +require_file "scripts/ci-versions.sh" require_contains "scripts/ci-check.sh" "RUN required:" -require_contains "scripts/ci-check.sh" "SKIP optional:" +require_contains "scripts/ci-check.sh" "Go lint version" +require_contains "scripts/ci-check.sh" "GOLANGCI_LINT_VERSION" +require_contains "scripts/ci-versions.sh" 'GOLANGCI_LINT_VERSION="2.12.2"' if grep -Fq "ci: fmt" Makefile; then fail "Makefile ci target must not run the mutating fmt target" fi @@ -159,7 +164,8 @@ require_contains ".gitea/workflows/bat.yml" "make test-go-api" require_contains ".gitea/workflows/bat.yml" "go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/..." require_contains ".gitea/workflows/bat.yml" "go build -o /tmp/bat-api ./cmd/bat-api" require_contains ".gitea/workflows/bat.yml" "Check Go formatting" -require_contains ".gitea/workflows/bat.yml" "SKIP optional: Go lint" +require_contains ".gitea/workflows/bat.yml" "Required Go lint" +require_contains ".gitea/workflows/bat.yml" "source scripts/ci-versions.sh" require_contains "Makefile" "cargo clippy --workspace --all-targets -- -D warnings" openapi_tmp="$(mktemp)" diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index 09d0dc3..4d40464 100644 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -9,6 +9,9 @@ export GOCACHE : "${XDG_CACHE_HOME:=/tmp/bat-xdg-cache}" export XDG_CACHE_HOME +source "${repo_root}/scripts/ci-versions.sh" +export GOLANGCI_LINT_VERSION + run_required() { local name="$1" shift @@ -52,24 +55,18 @@ run_required "Rust tests" cargo test --workspace --locked run_required "Go API tests" go test ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/... run_required "Go API vet" go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/... run_required "Go API build" go build -o /tmp/bat-api ./cmd/bat-api +run_required "Go lint version" bash -c ' + source scripts/ci-versions.sh + command -v golangci-lint >/dev/null 2>&1 || + { printf "golangci-lint %s is required but not installed\n" "${GOLANGCI_LINT_VERSION}" >&2; exit 1; } + actual="$(golangci_lint_actual_version)" + if [[ "${actual}" != "${GOLANGCI_LINT_VERSION}" ]]; then + printf "golangci-lint version mismatch: required=%s actual=%s\n" \ + "${GOLANGCI_LINT_VERSION}" "${actual:-unknown}" >&2 + exit 1 + fi +' +run_required "Go lint" golangci-lint run ./... run_required "Documentation, OpenAPI, and contract checks" make check-docs -optional_failed=0 -if command -v golangci-lint >/dev/null 2>&1; then - printf 'RUN optional: Go lint (golangci-lint)\n' - if golangci-lint run ./...; then - printf 'PASS optional: Go lint (golangci-lint)\n' - else - printf 'FAIL optional: Go lint (golangci-lint); reason=lint findings\n' >&2 - optional_failed=1 - fi -else - printf 'SKIP optional: Go lint (golangci-lint); reason=command not installed\n' -fi - -if ((optional_failed)); then - printf 'required check-only gates passed; optional gates failed\n' >&2 - exit 1 -fi - -printf 'required check-only gates passed; optional gates are reported above\n' +printf 'all required check-only gates passed\n' diff --git a/scripts/ci-versions.sh b/scripts/ci-versions.sh new file mode 100644 index 0000000..b140108 --- /dev/null +++ b/scripts/ci-versions.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +# Canonical versions for required local and Gitea CI tools. +GOLANGCI_LINT_VERSION="2.12.2" + +golangci_lint_actual_version() { + golangci-lint version 2>/dev/null | + sed -n 's/.*has version \([^ ]*\).*/\1/p' +}