fix(release):完善当前分发证明与质量门禁
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-14 06:39:47 +08:00
parent c17904ee1c
commit 13b0bd5b45
39 changed files with 1590 additions and 228 deletions
+5 -5
View File
@@ -249,18 +249,18 @@ jobs:
source /var/lib/act_runner/env.sh source /var/lib/act_runner/env.sh
go build -o /tmp/bat-api ./cmd/bat-api go build -o /tmp/bat-api ./cmd/bat-api
- name: Optional Go lint - name: Required Go lint
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
source /var/lib/act_runner/env.sh source /var/lib/act_runner/env.sh
if command -v golangci-lint >/dev/null 2>&1; then 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}" export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}"
golangci-lint run ./... golangci-lint run ./...
else
printf 'SKIP optional: Go lint (golangci-lint); reason=command not installed\n'
fi
- name: Run documentation status gate - name: Run documentation status gate
shell: bash shell: bash
+7 -5
View File
@@ -49,8 +49,10 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
`localized.status` 现将 generic manifest schema/contract 与已发布 artifact integrity `localized.status` 现将 generic manifest schema/contract 与已发布 artifact integrity
分开报告;current、state 和 identity 存在但文件被截断或手工修改时返回 分开报告;current、state 和 identity 存在但文件被截断或手工修改时返回
`localized.degraded`,只读检查不会自动回滚、删除或修复。双 release 的 `localized.degraded`,只读检查不会自动回滚、删除或修复。双 release 的
`release.status/list/distribution/cleanup` 已由 Rust 从既有状态、manifest、文件系统 `release.attestation` 已由 Rust 从 current、publication anchor、manifest identity
CAS/reference 元数据统一生成,Go 仅 typed 转发。CAS repository 的对象文件、引用计数 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 和 GC 通过跨进程操作锁协调,release-local CAS 引用以 `(ownership_id, ordinal)` ownership
记录幂等释放;新清单持久化 `ownership_id`,旧清单按 output-root scope、稳定 source 记录幂等释放;新清单持久化 `ownership_id`,旧清单按 output-root scope、稳定 source
identity 和 generation-aware legacy cleanup path 迁移;localized publish/rollback 通过 identity 和 generation-aware legacy cleanup path 迁移;localized publish/rollback 通过
@@ -263,8 +265,8 @@ marker 存在时才保持兼容 key,无法证明归属时拒绝 cleanup;完
| Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 | | Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 |
默认 Go/docs 只读门禁:`make ci-check`Rust fmt/check/build/clippy/test、Go API 默认 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` format/test/vet/build、固定版本 `golangci-lint 2.12.2`docs/OpenAPI/RPC contract
才会修改源码;`golangci-lint` 缺失时作为 optional gate 明确输出 skipped 无 FFI)。`make format` / `make fmt` 才会修改源码;required 工具缺失或版本不匹配直接失败
--- ---
@@ -308,7 +310,7 @@ cargo run -p bat-infrastructure --bin bat -- \
--watch --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 fixtureGo 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 fixtureGo 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。
生产要求: 生产要求:
+13 -8
View File
@@ -118,14 +118,19 @@ lint-rust: ## Rust Clippy 检查
@echo "$(BLUE)Running Clippy...$(NC)" @echo "$(BLUE)Running Clippy...$(NC)"
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
lint-go: ## Go Linter 检查 lint-go: ## Go Linter 检查required
@echo "$(BLUE)Running golangci-lint...$(NC)" @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; } @. scripts/ci-versions.sh; \
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \ command -v golangci-lint >/dev/null 2>&1 || { \
XDG_CACHE_HOME="$${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" golangci-lint run ./...; \ echo "$(YELLOW)required gate failed: golangci-lint $${GOLANGCI_LINT_VERSION} is not installed$(NC)"; \
else \ exit 1; \
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \ }; \
fi 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/CD
# ============================================================================ # ============================================================================
ci-check: ## 运行只读 required CI 门禁(可选 lint 会明确标记 ci-check: ## 运行只读 required CI 门禁(含固定版本 Go lint
@bash scripts/ci-check.sh @bash scripts/ci-check.sh
ci: ci-check ## 运行只读 CI 检查(兼容旧命令名) ci: ci-check ## 运行只读 CI 检查(兼容旧命令名)
+2 -1
View File
@@ -64,7 +64,8 @@ make ci-check
``` ```
`make ci-check` 是只读 required 门禁;`make format` / `make fmt` 才会格式化源码。 `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`;工具缺失或版本不匹配都会失败。
查看官方同步命令: 查看官方同步命令:
+23 -10
View File
@@ -62,7 +62,8 @@
* manifest entry 是否存在; * manifest entry 是否存在;
* 文件 size 是否匹配。 * 文件 size 是否匹配。
虽然 Rust 已有完整的发布状态、BLAKE3 校验与 release integrity 语义,但 Go 当前并未完整继承这一健康状态。 虽然 Rust 已有完整的发布状态、BLAKE3 校验与 release integrity 语义,但 Go 当前需要继承
一个轻量、带代际绑定和 freshness 的 current official health proof,而不是触发重型历史扫描。
因此可能出现: 因此可能出现:
@@ -76,9 +77,9 @@
建立统一的: 建立统一的:
```text ```text
Rust release health Rust current release attestation
Go ReleaseIndex Go ReleaseIndex(保留 release/publication/manifest identity
readiness / bootstrap readiness / bootstrap
@@ -93,6 +94,13 @@ CDN distribution gate
* bootstrap 不应报告 distributable * bootstrap 不应报告 distributable
* CDN 不应继续从该 release 分发任意文件。 * 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 * GET
@@ -143,10 +151,12 @@ B 同大小内容损坏,Rust health=false
## 完成记录 ## 完成记录
Rust `release.status.official_distribution_ready` 已接入 Go refresh snapshot 当前已完成 Rust current attestation、manifest 代际绑定、Go typed mirror、分页一致性和
`readyz`、bootstrap、release summary 和普通 current CDN 共用 whole-release A→B 切换 fail-closed 测试;`readyz`、bootstrap、release summary 和普通 current CDN
distribution gate。刷新失败会清空旧 index,恢复后重新建立快照;显式 共用 `attestation ready + exact manifest generation + complete local snapshot` gate。
official/localized/historical 分发仍经 Rust `release.distribution` 刷新失败会清空旧 index,恢复后重新建立快照;显式 official/localized/historical 分发
仍经 Rust `release.distribution``make ci-check``make test-go-api`
`make check-docs` 均已通过。
--- ---
@@ -544,14 +554,17 @@ CI 检查必须:
* 不修改源码; * 不修改源码;
* 明确报告每项 gate * 明确报告每项 gate
* required tool 缺失时不应伪装成全部通过; * required tool 缺失时不应伪装成全部通过;
* optional gate 明确标记 optional * required tool 缺失或版本不匹配必须失败
* 与实际 Gitea CI 尽量保持一致。 * 与实际 Gitea CI 尽量保持一致。
## 完成记录 ## 完成记录
`make format` / `make fmt` 保留为显式写入命令,`make ci-check` 和兼容的 `make format` / `make fmt` 保留为显式写入命令,`make ci-check` 和兼容的
`make ci` 只执行 read-only required gatesGo lint 在工具缺失时明确输出 `make ci` 只执行 read-only required gates`golangci-lint 2.12.2`
optional skippedOpenAPI、RPC contract 和文档一致性由 `make check-docs` 纳入。 `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` 均已通过。
建议统一覆盖: 建议统一覆盖:
+2 -2
View File
@@ -20,9 +20,9 @@ paths:
summary: Release readiness summary: Release readiness
responses: responses:
"200": "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": "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: /v1/bootstrap:
get: get:
summary: Startup resource bootstrap summary: Startup resource bootstrap
-4
View File
@@ -9,7 +9,3 @@ func InspectManifest(rawJSON string) (string, error) {
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) { func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
return ffi.BuildSyncPlan(currentJSON, previousJSON) return ffi.BuildSyncPlan(currentJSON, previousJSON)
} }
func batVersion() (string, error) {
return ffi.Version()
}
+2 -2
View File
@@ -21,6 +21,6 @@ func runSync(args []string) error {
return err return err
} }
fmt.Fprintln(os.Stdout, result) _, err = fmt.Fprintln(os.Stdout, result)
return nil return err
} }
@@ -371,10 +371,10 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
- CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download - CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。 manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再读取 - 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再读取
Rust `release.status` 的 whole-release `official_distribution_ready`,最后 Rust 轻量 `release.attestation`,最后按 attested release/publication/manifest
读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。 identity 读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket
Go 不重新实现 release verifier;普通 current CDN 只有在该 Rust health fact 文件)。Go 不重新实现 release verifier;普通 current CDN 只有在 attestation
和本地只读 manifest 快照都允许时才分发。 fresh/ready、分页快照完整且本地只读路径检查都允许时才分发。
- `/v1/releases``/v1/distribution` 和受保护的 `/admin/releases/status` - `/v1/releases``/v1/distribution` 和受保护的 `/admin/releases/status`
`/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed `/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed
结果;localized 或历史分发不会绕过 Rust 完整性判断。 结果;localized 或历史分发不会绕过 Rust 完整性判断。
+5 -4
View File
@@ -92,7 +92,7 @@
### 2.1 双 release 读写边界 ### 2.1 双 release 读写边界
Rust `bat``release.status` 是 official/localized 的统一只读视图,基于既有 Rust `bat``release.status` 是 official/localized 的统一重型只读视图,基于既有
version state、current symlink、release manifest、文件系统和必要的 CAS/reference version state、current symlink、release manifest、文件系统和必要的 CAS/reference
元数据计算,不建立第二个 release 数据库。`release.list` 返回两个 namespace 的当前 元数据计算,不建立第二个 release 数据库。`release.list` 返回两个 namespace 的当前
与历史 release,包含稳定 ID、created/published、source official relation、生命周期、 与历史 release,包含稳定 ID、created/published、source official relation、生命周期、
@@ -382,10 +382,11 @@ Addressables 改写后客户端拼接:
1. `daemon.status` 1. `daemon.status`
2. `daemon.doctor` 2. `daemon.doctor`
3. `release.status`,消费 Rust 返回的当前 official `official_distribution_ready` 3. `release.attestation`,消费 Rust 当前 official `ready`、release/publication/
及其 `status` / `status_code` / integrity 事实 manifest identity、verification generation、freshness 和 integrity 事实
4. `catalog.status``version.resource_root``addressables_root`、app/bundle 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 读快照, 6.`resource_root` 上 Lstat 校验 Present / size;该检查只验证 Go 读快照,
不替代 Rust release verifier 不替代 Rust release verifier
+1 -1
View File
@@ -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。 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` 不应格式化源码。 3. `make format` / `make fmt` 会修改工作树;`make ci-check``make check``make test``make lint` 不应格式化源码。
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture` 4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`
+1 -1
View File
@@ -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 缓存。 否则保持默认 `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/<id>` 不要在生产 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/<id>`attestation 过期或分页代际不一致时 fail closed
### 健康检查 ### 健康检查
+5 -3
View File
@@ -143,14 +143,16 @@ UnityFS / AssetBundle / Addressables / TypeTree 解析当前按路线图继续
make ci-check make ci-check
``` ```
`make ci-check` 是只读门禁入口;`make format` / `make fmt` 才会修改源码。缺少 `make ci-check` 是只读门禁入口;`make format` / `make fmt` 才会修改源码。
`golangci-lint` 时,Go lint 作为 optional gate 明确输出 skipped,不会伪报全部门禁通过。 required 的 `golangci-lint 2.12.2``scripts/ci-versions.sh` 固定,缺失或版本不匹配
都会失败,不会伪报全部门禁通过。
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准: Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
- **同步/运维命令行** = Rust `bat`(近乎全自动) - **同步/运维命令行** = Rust `bat`(近乎全自动)
- **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api``make build-go-api` - **资源 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` 重名 - 试验 CLI 产物为 `bin/bat-go``make build-go-cli`),**禁止**与 Rust `bat` 重名
- 修改 FFI 时再跑 `make test-go-ffi` - 修改 FFI 时再跑 `make test-go-ffi`
+15 -5
View File
@@ -104,8 +104,8 @@ contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 | | `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 | | `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 | | `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
| `resource.manifest` | 已实现 | `{ "offset": 0, "limit": 100 }` | 当前 download manifest 分页。 | | `resource.manifest` | 已实现 | `{ "release_id": "...", "expected_publication_identity": "...", "expected_manifest_identity": "...", "offset": 0, "limit": 100 }` | 绑定一个 Rust attested official generation 的 download manifest 分页。 |
| `resource.list` | 已实现 | `{ "offset": 0, "limit": 100 }` | `resource.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.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` `resource.repair` 会开启本地 manifest audit + repair,不继承 `force`
@@ -119,6 +119,14 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是 属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
`1..=1000`,非法参数返回 `BAT-ERR-700002` `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` `resource.index``entries[]``Resource` JSON,除 `id``local_path`
`entry` 外会包含 `metadata``official_release_id``platform` `entry` 外会包含 `metadata``official_release_id``platform`
`bundle_path``archive_entries``parse_statuses``unity_versions` `bundle_path``archive_entries``parse_statuses``unity_versions`
@@ -176,7 +184,8 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
| 方法 | 状态 | params | data | | 方法 | 状态 | 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.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.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`。 | | `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 | 用途 | | Go 接口 | 允许调用的 RPC | 用途 |
|---|---|---| |---|---|---|
| `Backend` | `daemon.status``daemon.doctor``resource.state``catalog.status``resource.manifest` | 启动发现、周期刷新和资源分发基础数据 | | `Backend` | `daemon.status``daemon.doctor``resource.state``catalog.status``release.attestation`、绑定后的 `resource.manifest` | 启动发现、周期刷新和资源分发基础数据 |
| `ReleaseStatusBackend` | `release.status` | 启动发现和周期刷新使用 Rust-owned whole-release distribution healthGo 不重新实现 verifier | | `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` | 鉴权后的管理控制白名单 | | `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 调度计划控制 | | `ScheduleBackend` | `schedule.list``schedule.add``schedule.update``schedule.remove``schedule.run` | 鉴权后的 dashboard 调度计划控制 |
| `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 | | `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 |
+5 -3
View File
@@ -76,9 +76,11 @@ launcher 资源引导兼容、只读 CDN path、readiness、OpenAPI、鉴权管
dashboard;翻译任务和 Rust-owned TM 的 summary/query/confirm 也通过 typed RPC dashboard;翻译任务和 Rust-owned TM 的 summary/query/confirm 也通过 typed RPC
转发。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。 转发。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。
普通 current release 的 readiness、bootstrap、release summary 和 CDN 共用 普通 current release 的 readiness、bootstrap、release summary 和 CDN 共用
Rust `release.status.official_distribution_ready` whole-release health factGo Rust `release.attestation` 是 current official 的轻量 health/publication proof,带
只建立 manifest 的读快照,不复制 Rust verifier。`make ci-check` 是只读统一门禁, release/publication/manifest identity、verification generation、freshness 和诊断;Go
缺少 `golangci-lint` 时明确报告 optional skipped。 只建立绑定同一代际的 manifest 读快照,不复制 Rust verifier。`release.status` 仍保留
为重型管理诊断。`make ci-check` 是只读统一门禁,required `golangci-lint 2.12.2`
缺失或版本不匹配直接失败。
仍需完成: 仍需完成:
+4 -4
View File
@@ -61,11 +61,11 @@
| ID | 约定 | | ID | 约定 |
|---|---| |---|---|
| F | 版本/清单经 **`bat.sock` JSON-RPC**`--socket`);不读 daemon 内部状态文件 | | 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/manifestmanifest 请求绑定 attested release/publication/manifest identity |
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 | | H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
| I | 生产中 Rust `bat``bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 | | I | 生产中 Rust `bat``bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 |
| J | Go 索引以 **manifest + 磁盘 Present/size** 建立可读快照,但不将其当作 release integrity | | 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 完全没有」等过时句 | | O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 |
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` | | P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
| Q | 空目录标明 reserved empty | | 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` | 已用 | 服务层模块名 | | Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped 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` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 | | RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped 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` 走同一 contractfake 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` | | 资源 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 固定 okmanifest/sync 走 FFI | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI |
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
+124 -22
View File
@@ -7,17 +7,19 @@ use bat_core::{ApiError, ErrorCode};
use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY; use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY;
use bat_infrastructure::{ use bat_infrastructure::{
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, 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, apply_unityfs_text_asset_patch_file, build_official_distribution_attestation,
changed_endpoint_urls, cleanup_releases, completed_worker_translation_workbench, build_release_list, build_release_status, changed_endpoint_urls, cleanup_releases,
diff_extended_snapshot, export_translation_workbench, gc_orphan_staging_with_cas_root, completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench,
get_translation_entry, inspect_localized_release_artifact, lexical_absolute, gc_orphan_staging_with_cas_root, get_translation_entry, inspect_localized_release_artifact,
localized_patch_operations_with_glossary_path, open_append_file, read_download_manifest_at, 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_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_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench,
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy,
select_release_distribution, set_translation, set_translation_checked_with_glossary_path, select_release_distribution, set_translation, set_translation_checked_with_glossary_path,
unset_translation, validate_output_root, validate_runtime_state_dir, 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, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig,
LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService, OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
@@ -1155,6 +1157,25 @@ struct LocalizedRollbackRpcParams {
localized_release_id: Option<String>, localized_release_id: Option<String>,
} }
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ResourceManifestRpcParams {
#[serde(default)]
release_id: Option<String>,
#[serde(default)]
expected_publication_identity: Option<String>,
#[serde(default)]
expected_manifest_identity: Option<String>,
#[serde(default)]
offset: usize,
#[serde(default = "default_rpc_manifest_limit")]
limit: usize,
}
fn default_rpc_manifest_limit() -> usize {
100
}
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。 // 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。
const RPC_METHOD_STATUS: &str = "daemon.status"; const RPC_METHOD_STATUS: &str = "daemon.status";
const RPC_METHOD_STOP: &str = "daemon.stop"; 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_PUBLISH: &str = "localized.publish";
const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback"; const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback";
const RPC_METHOD_RELEASE_STATUS: &str = "release.status"; 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_LIST: &str = "release.list";
const RPC_METHOD_RELEASE_DISTRIBUTION: &str = "release.distribution"; const RPC_METHOD_RELEASE_DISTRIBUTION: &str = "release.distribution";
const RPC_METHOD_RELEASE_CLEANUP: &str = "release.cleanup"; const RPC_METHOD_RELEASE_CLEANUP: &str = "release.cleanup";
@@ -1982,6 +2004,18 @@ fn dispatch_rpc_method(
"resource.state", "resource.state",
build_resource_state_report(state_dir), 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 => { RPC_METHOD_SCHEDULE_LIST => {
let params = request let params = request
.params .params
@@ -2084,23 +2118,27 @@ fn dispatch_rpc_method(
enqueue_task_envelope(tasks, TaskKind::Repair, false, request_id) enqueue_task_envelope(tasks, TaskKind::Repair, false, request_id)
} }
RPC_METHOD_RESOURCE_MANIFEST => { RPC_METHOD_RESOURCE_MANIFEST => {
let (offset, limit) = match rpc_page_params(request.params.as_ref()) { let params = match rpc_optional_struct_params::<ResourceManifestRpcParams>(
Ok(page) => page, request.params.as_ref(),
Err(error) => { 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( return rpc_envelope_error(
request_id, request_id,
ApiError::new( ApiError::new(
ErrorCode::RPC_INVALID_PARAMS, ErrorCode::RPC_INVALID_PARAMS,
"resource.manifest", RPC_METHOD_RESOURCE_MANIFEST,
error.to_string(), "limit 必须在 1..=1000 范围内",
), ),
) );
}
}; };
rpc_envelope_from_result( rpc_envelope_from_result(
request_id, request_id,
"resource.manifest", RPC_METHOD_RESOURCE_MANIFEST,
build_resource_manifest_report(state_dir, offset, limit), build_resource_manifest_report(state_dir, params),
) )
} }
RPC_METHOD_RESOURCE_INDEX => { RPC_METHOD_RESOURCE_INDEX => {
@@ -2969,39 +3007,102 @@ fn build_catalog_diff_report(state_dir: &Path) -> anyhow::Result<serde_json::Val
/// `resource.manifest`:当前版本下载 manifest 的分页查询。 /// `resource.manifest`:当前版本下载 manifest 的分页查询。
fn build_resource_manifest_report( fn build_resource_manifest_report(
state_dir: &Path, state_dir: &Path,
offset: usize, params: ResourceManifestRpcParams,
limit: usize,
) -> anyhow::Result<serde_json::Value> { ) -> anyhow::Result<serde_json::Value> {
let (_, version_state) = read_daemon_resource_state(state_dir)?; let (status_file, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state let current = version_state
.as_ref() .as_ref()
.and_then(|state| state.current_completed_version.as_ref()); .and_then(|state| state.current_completed_version.as_ref());
let Some(record) = current else { let Some(record) = current else {
return Ok(serde_json::json!({ "available": false })); 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 manifest = read_download_manifest_at(&record.resource_root).map_err(anyhow::Error::msg)?;
let Some(manifest) = manifest else { let Some(manifest) = manifest else {
return Ok(serde_json::json!({ return Ok(serde_json::json!({
"available": false, "available": false,
"release_id": record.id,
"resource_root": record.resource_root, "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(); let total_entries = manifest.entries.len();
// BTreeMap 按 URL 有序迭代,分页结果稳定。 // BTreeMap 按 URL 有序迭代,分页结果稳定。
let entries: Vec<_> = manifest let entries: Vec<_> = manifest
.entries .entries
.values() .values()
.skip(offset) .skip(params.offset)
.take(limit) .take(params.limit)
.cloned() .cloned()
.collect(); .collect();
Ok(serde_json::json!({ Ok(serde_json::json!({
"available": true, "available": true,
"channel": "official",
"release_id": record.id,
"resource_root": record.resource_root, "resource_root": record.resource_root,
"manifest_version": manifest.version, "manifest_version": manifest.version,
"publication_identity": publication_identity,
"mapping_identity": mapping_identity,
"manifest_identity": manifest_identity,
"generation": verification_generation,
"total_entries": total_entries, "total_entries": total_entries,
"offset": offset, "offset": params.offset,
"limit": limit, "limit": params.limit,
"entries": entries, "entries": entries,
})) }))
} }
@@ -5457,6 +5558,7 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
zip_error: item.zip_error.clone(), zip_error: item.zip_error.clone(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
verify_and_record_official_distribution_attestation(&config)?;
let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate
&& update_report.local_manifest_repair_needed_count == 0 && update_report.local_manifest_repair_needed_count == 0
&& verification.is_clean(); && verification.is_clean();
+21
View File
@@ -4453,6 +4453,9 @@ fn dispatch_resource_manifest_paginates() {
assert_eq!(value["data"]["available"], true); assert_eq!(value["data"]["available"], true);
assert_eq!(value["data"]["total_entries"], 3); assert_eq!(value["data"]["total_entries"], 3);
assert_eq!(value["data"]["offset"], 1); 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(); let entries = value["data"]["entries"].as_array().unwrap();
assert_eq!(entries.len(), 2); assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["destination"], "b"); assert_eq!(entries[0]["destination"], "b");
@@ -4486,6 +4489,24 @@ fn dispatch_resource_manifest_paginates() {
let entries = value["data"]["entries"].as_array().unwrap(); let entries = value["data"]["entries"].as_array().unwrap();
assert_eq!(entries.len(), 1); assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["destination"], "c"); 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) { fn write_resource_index_fixture(repository_path: &Path) {
@@ -603,6 +603,14 @@ pub(super) fn run_task_worker(
) )
}; };
run_result 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)) .map(|report| serde_json::to_value(&report).map_err(anyhow::Error::from))
.and_then(|result| result) .and_then(|result| result)
}; };
+18 -15
View File
@@ -82,13 +82,15 @@ pub use official_changes::{
pub use official_download::{ pub use official_download::{
official_distribution_mapping_identity, read_cas_reuse_reference_manifest_at, official_distribution_mapping_identity, read_cas_reuse_reference_manifest_at,
read_download_manifest_at, release_cas_reuse_references, DownloadError, read_download_manifest_at, release_cas_reuse_references, DownloadError,
OfficialCasReuseReferenceManifest, OfficialDownloadManifest, OfficialDownloadManifestEntry, OfficialCasReuseReferenceManifest, OfficialDistributionAttestation, OfficialDownloadManifest,
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport, OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem,
OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus,
OfficialResourceHashAlgorithm, OfficialResourceHashVerification, OfficialResourcePullItem, OfficialLocalVerificationReport, OfficialResourceHashAlgorithm,
OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress,
OfficialResourcePullService, OfficialResourcePullStatus, OfficialResourceReuseWarning, OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
OfficialResourceVerification, OFFICIAL_CAS_REUSE_REFERENCES_FILE, 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, OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
}; };
pub use official_game_main_config::OfficialGameMainConfigBootstrapService; pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
@@ -130,13 +132,13 @@ pub use official_textunit_queue::{
pub use official_update::{ pub use official_update::{
cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging, 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, gc_orphan_staging_with_cas_root, read_bootstrap_cache, read_snapshot, read_version_state,
write_bootstrap_cache, write_snapshot, write_version_state, ExtendedSnapshotDelta, verify_and_record_official_distribution_attestation, write_bootstrap_cache, write_snapshot,
GameMainConfigSnapshot, LauncherMetadataSnapshot, LocalizedReleaseStatus, write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, LocalizedReleaseStatus, OfficialBootstrapCache, OfficialEndpointMarkerRole,
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig, OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource,
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
ResolvedBootstrap, OfficialVersionRecord, OfficialVersionState, ResolvedBootstrap,
}; };
pub use patch_ops::{ pub use patch_ops::{
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, 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_flow::ReleaseFlowStatusCode;
pub use release_ops::{ 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, ReleaseCleanupParams, ReleaseCleanupReport, ReleaseDistributionEntry, ReleaseDistributionPage,
ReleaseDistributionParams, ReleaseListParams, ReleaseStatusReport, ReleaseSummary, ReleaseDistributionParams, ReleaseListParams, ReleaseStatusReport, ReleaseSummary,
}; };
+154
View File
@@ -71,6 +71,12 @@ const DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json"; const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
/// Independent publication fact for the official distribution manifest. /// Independent publication fact for the official distribution manifest.
pub const OFFICIAL_DISTRIBUTION_PUBLICATION_FILE: &str = "official-distribution-publication.json"; 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 引用。 /// 记录一个已发布官方 release 获取的 CAS 引用。
pub const OFFICIAL_CAS_REUSE_REFERENCES_FILE: &str = "official-cas-reuse-references.json"; pub const OFFICIAL_CAS_REUSE_REFERENCES_FILE: &str = "official-cas-reuse-references.json";
const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1; const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1;
@@ -566,6 +572,154 @@ pub(crate) struct OfficialDistributionPublicationAnchor {
const OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION: u32 = 1; 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<u64>,
/// 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<String>,
}
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<Option<OfficialDistributionAttestation>, 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<String>,
) -> Result<OfficialDistributionAttestation, String> {
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 /// Writes the independent publication anchor after the complete official
/// release verification has succeeded. /// release verification has succeeded.
/// ///
+83 -1
View File
@@ -14,7 +14,8 @@ use crate::official_changes::{
OfficialResourceChangeSummary, OfficialResourceChangeSummary,
}; };
use crate::official_download::{ 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, OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
}; };
use crate::official_game_main_config::{ use crate::official_game_main_config::{
@@ -1785,6 +1786,28 @@ impl OfficialUpdateService {
&active_resource_root, &active_resource_root,
&snapshot_path, &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 = let active_launcher_bootstrap_path =
active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE); active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE);
if bootstrap.is_some() if bootstrap.is_some()
@@ -2015,6 +2038,13 @@ impl OfficialUpdateService {
&publish_plan.id, &publish_plan.id,
) )
.map_err(anyhow::Error::msg)?; .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( progress(OfficialUpdateProgress::new(
"snapshot", "snapshot",
format!("写入快照 {}", staging_snapshot_path.display()), 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<OfficialDistributionAttestation> {
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::<Vec<_>>();
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( fn run_post_sync_resource_handoff(
previous_resource_root: Option<&Path>, previous_resource_root: Option<&Path>,
current_resource_root: &Path, current_resource_root: &Path,
+308 -3
View File
@@ -11,9 +11,11 @@ use crate::localized_patch::{
LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSIONS_DIR,
}; };
use crate::official_download::{ use crate::official_download::{
read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references, official_distribution_publication_identity, read_cas_reuse_reference_manifest_at,
verify_official_distribution_publication_at, OfficialDownloadManifest, read_download_manifest_at, read_official_distribution_attestation_at,
OfficialDownloadManifestEntry, 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::official_update::{read_version_state, OfficialVersionRecord, OfficialVersionState};
use crate::path_security::{ use crate::path_security::{
@@ -24,6 +26,7 @@ use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::fs; use std::fs;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const OFFICIAL_CURRENT_LINK: &str = "current"; const OFFICIAL_CURRENT_LINK: &str = "current";
const OFFICIAL_STAGING_DIR: &str = ".staging"; const OFFICIAL_STAGING_DIR: &str = ".staging";
@@ -148,6 +151,47 @@ pub struct ReleaseStatusReport {
pub releases: Vec<ReleaseSummary>, pub releases: Vec<ReleaseSummary>,
} }
/// 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<u64>,
/// Freshness window used by the reader.
pub max_age_seconds: u64,
/// Diagnostics explaining a blocked result.
pub diagnostics: Vec<String>,
}
/// A page of resources from a Rust-verified release choice. /// A page of resources from a Rust-verified release choice.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ReleaseDistributionPage { 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<OfficialDistributionAttestationReport> {
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. /// Lists one or both release namespaces.
pub fn build_release_list( pub fn build_release_list(
official_root: &Path, official_root: &Path,
@@ -1793,6 +2027,77 @@ mod tests {
assert!(!official_blocked.available); 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] #[test]
fn distribution_selection_does_not_fallback_from_damaged_localized_release() { fn distribution_selection_does_not_fallback_from_damaged_localized_release() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -1332,7 +1332,7 @@ func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunPa
func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryConfirmParams) error { func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryConfirmParams) error {
if strings.TrimSpace(params.RecordID) == "" || strings.TrimSpace(params.Reviewer) == "" { 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 return nil
} }
+111 -3
View File
@@ -92,8 +92,14 @@ func fixtureRPCBackend(t *testing.T, root string) *fakeBackend {
catalog: catalog, catalog: catalog,
manifest: &backendrpc.ResourceManifestPage{ manifest: &backendrpc.ResourceManifestPage{
Available: true, Available: true,
Channel: "official",
ReleaseID: "official-fixture",
ResourceRoot: root, ResourceRoot: root,
ManifestVersion: 1, ManifestVersion: 1,
PublicationIdentity: "fixture-publication-v1",
MappingIdentity: "fixture-mapping-v1",
ManifestIdentity: "fixture-manifest-v1",
Generation: 1,
TotalEntries: len(manifestEntries), TotalEntries: len(manifestEntries),
Entries: manifestEntries, Entries: manifestEntries,
}, },
@@ -821,13 +827,16 @@ type fakeBackend struct {
statusCalls int statusCalls int
doctorCalls int doctorCalls int
releaseStatusCalls int releaseStatusCalls int
attestationCalls int
status *backendrpc.DaemonStatusReport status *backendrpc.DaemonStatusReport
doctor *backendrpc.DoctorReport doctor *backendrpc.DoctorReport
releaseStatus *backendrpc.ReleaseStatusReport releaseStatus *backendrpc.ReleaseStatusReport
releaseStatusErr error releaseStatusErr error
attestation *backendrpc.DistributionAttestation
catalog json.RawMessage catalog json.RawMessage
resource *backendrpc.ResourceState resource *backendrpc.ResourceState
manifest *backendrpc.ResourceManifestPage manifest *backendrpc.ResourceManifestPage
manifestParams []backendrpc.ResourceManifestParams
daemonLogs *backendrpc.LogsReport daemonLogs *backendrpc.LogsReport
taskList *backendrpc.TaskList taskList *backendrpc.TaskList
taskStatus *backendrpc.TaskRecord taskStatus *backendrpc.TaskRecord
@@ -858,6 +867,77 @@ func (f *fakeBackend) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseSta
OfficialDistributionReady: true, OfficialDistributionReady: true,
}, nil }, 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) { func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
if f.resource != nil { if f.resource != nil {
return 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) { func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return f.catalog, nil return f.catalog, nil
} }
func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { func (f *fakeBackend) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
return f.manifest, nil 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) { func (f *fakeBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
if f.daemonLogs != nil { 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) { func (p *pollingBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return nil, errors.New("unexpected catalog call") 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") 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) { func TestStartRefreshLoopPollsBackend(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
+2 -1
View File
@@ -332,7 +332,8 @@ BAT_API_STATE_DIR=/tmp/bat-pid
# BAT_API_SOCKET=/tmp/bat-pid/bat.sock # BAT_API_SOCKET=/tmp/bat-pid/bat.sock
# Optional override of the published release root (fixtures / emergency only). # 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= # BAT_API_RESOURCE_ROOT=
# Optional server-info JSON for Addressables root rewrite # Optional server-info JSON for Addressables root rewrite
+20 -1
View File
@@ -24,12 +24,14 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
availableRaw := readContractFixture(t, "catalog-status.available.json") availableRaw := readContractFixture(t, "catalog-status.available.json")
unavailableRaw := readContractFixture(t, "catalog-status.unavailable.json") unavailableRaw := readContractFixture(t, "catalog-status.unavailable.json")
manifestRaw := readContractFixture(t, "resource-manifest.page0.json") manifestRaw := readContractFixture(t, "resource-manifest.page0.json")
attestationRaw := readContractFixture(t, "release-attestation.json")
snapshotRaw := readContractFixture(t, "official-sync-snapshot.json") snapshotRaw := readContractFixture(t, "official-sync-snapshot.json")
for name, raw := range map[string][]byte{ for name, raw := range map[string][]byte{
"catalog available": availableRaw, "catalog available": availableRaw,
"catalog unavailable": unavailableRaw, "catalog unavailable": unavailableRaw,
"resource manifest": manifestRaw, "resource manifest": manifestRaw,
"attestation": attestationRaw,
"snapshot": snapshotRaw, "snapshot": snapshotRaw,
} { } {
if bytes.Contains(raw, []byte("/tmp/")) { if bytes.Contains(raw, []byte("/tmp/")) {
@@ -71,7 +73,12 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
if err := json.Unmarshal(manifestRaw, &manifest); err != nil { if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
t.Fatalf("decode resource manifest: %v", err) 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) t.Fatalf("manifest header=%+v", manifest)
} }
if len(manifest.Entries) != 2 { if len(manifest.Entries) != 2 {
@@ -84,6 +91,18 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
t.Fatalf("manifest first entry=%+v", manifest.Entries[0]) 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 { var snapshot struct {
AppVersion string `json:"app_version"` AppVersion string `json:"app_version"`
BundleVersion string `json:"bundle_version"` BundleVersion string `json:"bundle_version"`
+2 -2
View File
@@ -27,9 +27,9 @@ paths:
summary: Release readiness summary: Release readiness
responses: responses:
"200": "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": "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: /v1/bootstrap:
get: get:
summary: Startup resource bootstrap summary: Startup resource bootstrap
+241
View File
@@ -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())
}
})
}
}
+17 -5
View File
@@ -57,13 +57,21 @@ type GameMainConfigSummary struct {
// DistributionHealth is the release-level authorization used by read paths. // DistributionHealth is the release-level authorization used by read paths.
// //
// In RPC mode Ready is copied from Rust's release.status // In RPC mode these fields are copied from Rust's current official
// official_distribution_ready fact. The local manifest checks only establish // attestation. The local manifest checks only establish that this process has
// that this process has a usable read snapshot; they do not replace Rust's // a complete, safe read snapshot; they do not replace Rust's verifier.
// release verifier.
type DistributionHealth struct { type DistributionHealth struct {
Ready bool `json:"ready"` Ready bool `json:"ready"`
Source string `json:"source"` 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"` Status string `json:"status,omitempty"`
StatusCode string `json:"status_code,omitempty"` StatusCode string `json:"status_code,omitempty"`
IntegrityStatus string `json:"integrity_status,omitempty"` IntegrityStatus string `json:"integrity_status,omitempty"`
@@ -122,7 +130,11 @@ func (idx *ReleaseIndex) Summary() ReleaseSummary {
if distribution.Source == "" { if distribution.Source == "" {
distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest" distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest"
} else { } else {
distribution.Ready = distribution.Ready && localComplete distribution.Ready = distribution.Ready &&
localComplete &&
(idx.Source != "rpc" ||
(distribution.ManifestIdentity != "" &&
distribution.EntryCount == len(idx.Entries)))
} }
return ReleaseSummary{ return ReleaseSummary{
ResourceRoot: idx.ResourceRoot, ResourceRoot: idx.ResourceRoot,
+161 -61
View File
@@ -12,16 +12,18 @@ import (
// Backend is the subset of daemon RPC used by bat-api. // 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 // 1. daemon.status
// 2. daemon.doctor // 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 { type Backend interface {
DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error)
DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error)
ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error)
CatalogStatus(ctx context.Context) (json.RawMessage, 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 // 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) { func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.CatalogStatus(ctx) return r.Client.CatalogStatus(ctx)
} }
func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { func (r RPCClient) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
return r.Client.ResourceManifest(ctx, offset, limit) 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) { func (r RPCClient) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) {
return r.Client.DaemonRestart(ctx) return r.Client.DaemonRestart(ctx)
@@ -310,6 +315,7 @@ type DiscoverResult struct {
DoctorHealthy *bool DoctorHealthy *bool
Status *backendrpc.DaemonStatusReport Status *backendrpc.DaemonStatusReport
Doctor *backendrpc.DoctorReport Doctor *backendrpc.DoctorReport
Attestation *backendrpc.DistributionAttestation
ReleaseStatus *backendrpc.ReleaseStatusReport ReleaseStatus *backendrpc.ReleaseStatusReport
Distribution DistributionHealth Distribution DistributionHealth
Snapshot *SnapshotSummary Snapshot *SnapshotSummary
@@ -318,10 +324,11 @@ type DiscoverResult struct {
Warnings []string Warnings []string
} }
// DiscoverAndIndex contacts the daemon (status first, then doctor, then // DiscoverAndIndex contacts the daemon (status first, then doctor, then the
// release.status) and builds a release index from paginated resource.manifest // lightweight current-release attestation) and builds a release index from
// plus on-disk checks. Rust's release.status is the only release-level // pages bound to that attestation plus on-disk checks. Rust's attestation is
// integrity authorization used for the production RPC path. // 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 // If resourceRootOverride is non-empty, it wins over RPC-reported roots after
// RPC health probes (still preferred for production to call status/doctor). // 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 return out, nil
} }
// 3) release.status is the Rust-owned whole-release distribution gate. // 3) release.attestation is the Rust-owned current-release distribution gate.
releaseStatusBackend, ok := backend.(ReleaseStatusBackend) attestationBackend, ok := backend.(AttestationBackend)
if !ok { 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"), return emptyRPCResult(out, nil, "Rust release health is unavailable"),
fmt.Errorf("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 { 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"), 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 { if attestation == nil {
out.Warnings = append(out.Warnings, "release.status: empty response") out.Warnings = append(out.Warnings, "release.attestation: empty response")
return emptyRPCResult(out, nil, "Rust release health query returned no 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 // Catalog / resource discovery
var snapshot *SnapshotSummary var snapshot *SnapshotSummary
var resourceRoot string resourceRoot := attestation.ResourceRoot
catalogAvailabilityKnown := false catalogAvailabilityKnown := false
catalogAvailable := false catalogAvailable := false
@@ -429,18 +439,31 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
} }
if snapshot != nil && if snapshot != nil &&
snapshot.VersionID != "" && snapshot.VersionID != "" &&
out.ReleaseStatus.OfficialCurrentReleaseID != "" && attestation.ReleaseID != "" &&
snapshot.VersionID != out.ReleaseStatus.OfficialCurrentReleaseID { snapshot.VersionID != attestation.ReleaseID {
return emptyRPCResult( return emptyRPCResult(
out, out,
snapshot, snapshot,
fmt.Sprintf( fmt.Sprintf(
"release.status current ID %q does not match catalog current ID %q", "release.attestation current ID %q does not match catalog current ID %q",
out.ReleaseStatus.OfficialCurrentReleaseID, attestation.ReleaseID,
snapshot.VersionID, snapshot.VersionID,
), ),
), nil ), 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 { if catalogAvailabilityKnown && !catalogAvailable {
return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil 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 { if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err)) out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
// Without the RPC manifest there is no evidence that the local // Without the RPC manifest there is no evidence that the local
@@ -498,6 +521,17 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
), nil ), nil
} }
if rootFromManifest != "" { 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 resourceRoot = rootFromManifest
} }
if resourceRootOverride != "" { if resourceRootOverride != "" {
@@ -530,6 +564,13 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return out, nil 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 { func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult {
distribution := out.Distribution distribution := out.Distribution
if distribution.Source == "" { if distribution.Source == "" {
@@ -553,52 +594,48 @@ func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning stri
return out return out
} }
func rustDistributionHealth(report *backendrpc.ReleaseStatusReport) DistributionHealth { func rustAttestationHealth(report *backendrpc.DistributionAttestation) DistributionHealth {
health := DistributionHealth{ health := DistributionHealth{
Source: "rust_release_status", Source: "rust_release_attestation",
Status: "blocked", Status: "unavailable",
StatusCode: "distribution.blocked", StatusCode: "distribution.attestation_unavailable",
IntegrityStatus: "unknown", IntegrityStatus: "unavailable",
} }
if report == nil { if report == nil {
return health 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.Status = report.Status
health.StatusCode = report.StatusCode health.StatusCode = report.StatusCode
health.IntegrityStatus = report.IntegrityStatus
health.Diagnostics = append([]string(nil), report.Diagnostics...)
if health.Status == "" { if health.Status == "" {
if health.Ready { health.Status = "unavailable"
health.Status = "ready"
} else {
health.Status = "blocked"
}
} }
if health.StatusCode == "" { if health.StatusCode == "" {
if health.Ready { health.StatusCode = "distribution.attestation_unavailable"
health.StatusCode = "distribution.ready"
} else {
health.StatusCode = "distribution.blocked"
} }
} if health.IntegrityStatus == "" {
for _, release := range report.Releases { health.IntegrityStatus = "unavailable"
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"
} }
return health return health
} }
func unavailableRustDistributionHealth() DistributionHealth { func unavailableRustDistributionHealth() DistributionHealth {
return DistributionHealth{ return DistributionHealth{
Source: "rust_release_status", Source: "rust_release_attestation",
Status: "unavailable", Status: "unavailable",
StatusCode: "distribution.health_unavailable", StatusCode: "distribution.attestation_unavailable",
IntegrityStatus: "unknown", IntegrityStatus: "unavailable",
} }
} }
@@ -675,25 +712,85 @@ func parseCatalogAvailability(raw json.RawMessage) (bool, bool) {
return *payload.Available, true return *payload.Available, true
} }
func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEntry, int, string, error) { func fetchAllManifestEntries(
const pageSize = 500 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 offset := 0
var all []manifestEntry var all []manifestEntry
var version int var version int
var root string var root, releaseID, publicationIdentity, mappingIdentity, manifestIdentity string
total := -1
for { 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 { if err != nil {
return nil, 0, "", err return nil, 0, "", err
} }
if page == nil {
return nil, 0, "", fmt.Errorf("resource.manifest returned nil page")
}
if !page.Available { if !page.Available {
return nil, 0, "", fmt.Errorf("resource.manifest available=false") return nil, 0, "", fmt.Errorf("resource.manifest available=false")
} }
if root == "" { if root == "" {
root = page.ResourceRoot root = page.ResourceRoot
} releaseID = page.ReleaseID
if version == 0 { publicationIdentity = page.PublicationIdentity
mappingIdentity = page.MappingIdentity
manifestIdentity = page.ManifestIdentity
version = page.ManifestVersion 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 { for _, e := range page.Entries {
var bytes uint64 var bytes uint64
@@ -708,9 +805,12 @@ func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEn
}) })
} }
offset += len(page.Entries) offset += len(page.Entries)
if len(page.Entries) == 0 || offset >= page.TotalEntries { if offset == total {
break 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 return all, version, root, nil
} }
+3 -1
View File
@@ -7,7 +7,9 @@ contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本
覆盖范围: 覆盖范围:
- `catalog.status` 可用与不可用响应。 - `catalog.status` 可用与不可用响应。
- `resource.manifest` 第一页分页响应 - `resource.manifest` 第一页分页响应,包含 release/publication/mapping/manifest
identity 和 generation 绑定字段。
- `release.attestation` 当前 official health/publication proof。
- 对应 release 的 `official-sync-snapshot.json` - 对应 release 的 `official-sync-snapshot.json`
- `launcher_metadata``game_main_config_bootstrap` 的 Go mirror 解码。 - `launcher_metadata``game_main_config_bootstrap` 的 Go mirror 解码。
- Rust Glossary V1 query 响应,覆盖 alias、approved review、source provenance 和完整 history。 - Rust Glossary V1 query 响应,覆盖 alias、approved review、source provenance 和完整 history。
+18
View File
@@ -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
}
@@ -1,5 +1,6 @@
{ {
"available": true, "available": true,
"channel": "official",
"entries": [ "entries": [
{ {
"blake3": "0000000000000000000000000000000000000000000000000000000000000000", "blake3": "0000000000000000000000000000000000000000000000000000000000000000",
@@ -14,9 +15,14 @@
"url": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.hash" "url": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.hash"
} }
], ],
"generation": 7,
"limit": 2, "limit": 2,
"manifest_identity": "${MANIFEST_IDENTITY}",
"manifest_version": 1, "manifest_version": 1,
"mapping_identity": "${MAPPING_IDENTITY}",
"offset": 0, "offset": 0,
"publication_identity": "${PUBLICATION_IDENTITY}",
"release_id": "${VERSION_ID}",
"resource_root": "${RESOURCE_ROOT}", "resource_root": "${RESOURCE_ROOT}",
"total_entries": 2 "total_entries": 2
} }
+44 -3
View File
@@ -118,7 +118,7 @@ func (c *Client) callEnvelope(ctx context.Context, method string, params any) (*
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer conn.Close() defer func() { _ = conn.Close() }()
if deadline, ok := c.deadline(ctx); ok { if deadline, ok := c.deadline(ctx); ok {
_ = conn.SetDeadline(deadline) _ = conn.SetDeadline(deadline)
@@ -877,10 +877,25 @@ type ResourceManifestEntry struct {
BLAKE3 string `json:"blake3,omitempty"` 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 { type ResourceManifestPage struct {
Available bool `json:"available"` Available bool `json:"available"`
Channel string `json:"channel,omitempty"`
ReleaseID string `json:"release_id,omitempty"`
ResourceRoot string `json:"resource_root,omitempty"` ResourceRoot string `json:"resource_root,omitempty"`
ManifestVersion int `json:"manifest_version,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"` TotalEntries int `json:"total_entries,omitempty"`
Offset int `json:"offset,omitempty"` Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"` Limit int `json:"limit,omitempty"`
@@ -953,9 +968,35 @@ func (c *Client) ResourceRepair(ctx context.Context) (*TaskAccepted, error) {
return &out, err 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 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 return &out, err
} }
+95
View File
@@ -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, &params); 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) { func TestUnityFSPatchFieldSendsTaggedReplacement(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "unityfs.patch_field" { if req.Method != "unityfs.patch_field" {
+9 -3
View File
@@ -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" "daemon.clean-stable"
require_contains "docs/reference/rpc-backend-api.md" "localized_release_status" 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/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" "check-docs:"
require_contains "Makefile" "format: fmt" require_contains "Makefile" "format: fmt"
require_contains "Makefile" "ci-check:" require_contains "Makefile" "ci-check:"
require_contains "Makefile" "ci: ci-check" require_contains "Makefile" "ci: ci-check"
require_file "scripts/ci-check.sh" 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" "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 if grep -Fq "ci: fmt" Makefile; then
fail "Makefile ci target must not run the mutating fmt target" fail "Makefile ci target must not run the mutating fmt target"
fi 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 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" "go build -o /tmp/bat-api ./cmd/bat-api"
require_contains ".gitea/workflows/bat.yml" "Check Go formatting" 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" require_contains "Makefile" "cargo clippy --workspace --all-targets -- -D warnings"
openapi_tmp="$(mktemp)" openapi_tmp="$(mktemp)"
+15 -18
View File
@@ -9,6 +9,9 @@ export GOCACHE
: "${XDG_CACHE_HOME:=/tmp/bat-xdg-cache}" : "${XDG_CACHE_HOME:=/tmp/bat-xdg-cache}"
export XDG_CACHE_HOME export XDG_CACHE_HOME
source "${repo_root}/scripts/ci-versions.sh"
export GOLANGCI_LINT_VERSION
run_required() { run_required() {
local name="$1" local name="$1"
shift 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 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 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 API build" go build -o /tmp/bat-api ./cmd/bat-api
run_required "Documentation, OpenAPI, and contract checks" make check-docs run_required "Go lint version" bash -c '
source scripts/ci-versions.sh
optional_failed=0 command -v golangci-lint >/dev/null 2>&1 ||
if command -v golangci-lint >/dev/null 2>&1; then { printf "golangci-lint %s is required but not installed\n" "${GOLANGCI_LINT_VERSION}" >&2; exit 1; }
printf 'RUN optional: Go lint (golangci-lint)\n' actual="$(golangci_lint_actual_version)"
if golangci-lint run ./...; then if [[ "${actual}" != "${GOLANGCI_LINT_VERSION}" ]]; then
printf 'PASS optional: Go lint (golangci-lint)\n' printf "golangci-lint version mismatch: required=%s actual=%s\n" \
else "${GOLANGCI_LINT_VERSION}" "${actual:-unknown}" >&2
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 exit 1
fi fi
'
run_required "Go lint" golangci-lint run ./...
run_required "Documentation, OpenAPI, and contract checks" make check-docs
printf 'required check-only gates passed; optional gates are reported above\n' printf 'all required check-only gates passed\n'
+9
View File
@@ -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'
}