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