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-15 21:13:52 +08:00
parent 13b0bd5b45
commit 99355effe4
29 changed files with 1137 additions and 189 deletions
+1 -3
View File
@@ -229,9 +229,7 @@ jobs:
set -euo pipefail
source /var/lib/act_runner/env.sh
files="$(git ls-files '*.go')"
test -n "${files}"
test -z "$(gofmt -l ${files})"
make check-go-format
- name: Run Go API vet
shell: bash
+4 -3
View File
@@ -49,8 +49,9 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
`localized.status` 现将 generic manifest schema/contract 与已发布 artifact integrity
分开报告;current、state 和 identity 存在但文件被截断或手工修改时返回
`localized.degraded`,只读检查不会自动回滚、删除或修复。双 release 的
`release.attestation` 已由 Rust 从 current、publication anchor、manifest identity 和
verification freshness 生成,供 Go current readiness 使用;`release.status/list/distribution/cleanup`
`release.attestation` 已由 Rust 从 current、canonical versioned resource root、publication
anchor、manifest identity 和 verification generation/freshness 生成,供 Go current
readiness 使用;`resource.manifest` 请求必须绑定同一 generation`release.status/list/distribution/cleanup`
仍由 Rust 从既有状态、manifest、文件系统和 CAS/reference 元数据统一生成,Go 仅 typed 转发。
CAS repository 的对象文件、引用计数
和 GC 通过跨进程操作锁协调,release-local CAS 引用以 `(ownership_id, ordinal)` ownership
@@ -310,7 +311,7 @@ cargo run -p bat-infrastructure --bin bat -- \
--watch
```
资源 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。
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取 Rust 当前 official `release.attestation`再按 release/publication/mapping/manifest identity 和 verification generation 绑定读取 `resource.manifest`,不在配置里写死资源目录;轻量 attestation 只读取 current、canonical versioned root、publication anchor、manifest 元数据和 freshness,不遍历历史 release 或计算资源文件 BLAKE3。Rust watch 周期负责 current 本地 manifest 验证并更新 attestation,默认 freshness window 为 `2 * 3600 + 60 = 7260` 秒;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。
生产要求:
+5 -1
View File
@@ -1,4 +1,4 @@
.PHONY: help build build-ffi test clean check check-docs format fmt lint ci ci-check install dev docker-build docker-up docker-down official-smoke bat-api-local-live-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
.PHONY: help build build-ffi test clean check check-docs check-go-format format fmt lint ci ci-check install dev docker-build docker-up docker-down official-smoke bat-api-local-live-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
# 默认目标
.DEFAULT_GOAL := help
@@ -96,6 +96,10 @@ check-go: ## 检查 Go 代码
@echo "$(BLUE)Checking Go code...$(NC)"
go vet ./...
check-go-format: ## 检查 Go 格式(只读)
@echo "$(BLUE)Checking Go formatting...$(NC)"
bash scripts/check-go-format.sh
check-docs: ## 检查权威状态文档与占位目录声明
@echo "$(BLUE)Checking documentation status claims...$(NC)"
bash scripts/check-doc-status.sh
+21 -15
View File
@@ -94,11 +94,13 @@ CDN distribution gate
* bootstrap 不应报告 distributable
* CDN 不应继续从该 release 分发任意文件。
`release.attestation` 必须由 Rust 维护 publication anchor、manifest identity
verification generation、verified_at、integrity/status code 和 diagnostics;轻量读取
`release.attestation` 必须由 Rust 维护 canonical resource root、publication anchor、
mapping/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 逐页验证
文件 BLAKE3。`resource.manifest` 请求必须绑定 attested `release_id`
`publication_identity``manifest_identity``expected_verification_generation`,每页返回相同
代际信息;Go 逐页验证
release/root/identity/version/total/offset/limit,任何混页都丢弃候选快照。
保持现有:
@@ -151,12 +153,15 @@ B 同大小内容损坏,Rust health=false
## 完成记录
当前已完成 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` 均已通过。
已收口 Rust current official attestation 到 canonical `versions/<id>` rootpublication
在 staging 写入但 JSON root 绑定最终 versions pathrename 不增加 verification
generationfull audit、显式 verify/repair、watch current audit 的 verified/invalid 均写入
新 generation。`resource.manifest` 请求和响应按 release/publication/mapping/manifest/
generation 严格绑定,Go 分页逐页及最终 entry count 校验;freshness 按
`2 * verification_interval + error_retry` 计算,默认 `7260` 秒,`max_age_seconds=0`
fail closed。`readyz`、bootstrap 和普通 current CDN 共用 Rust attestation、完整分页及
安全本地快照 gate;失败 verification 会立即失效旧 ready generation。已增加 staging
transition、generation race、过期和失败失效回归,并验证现有 CDN/历史分发路径未改语义。
---
@@ -560,11 +565,12 @@ CI 检查必须:
## 完成记录
`make format` / `make fmt` 保留为显式写入命令,`make ci-check` 和兼容的
`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` 均已通过。
`make ci` 只执行 read-only required gates共享 `scripts/check-go-format.sh`
`make check-go-format`、本地 `scripts/ci-check.sh` 和 Gitea workflow 共用。`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。最终 workspace gates、`make test-go-api`
`make check-docs``make ci-check` 均已通过。
建议统一覆盖:
@@ -371,8 +371,8 @@ 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.attestation`,最后按 attested release/publication/manifest
identity 读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket
Rust 轻量 `release.attestation`,最后按 attested release/publication/mapping/manifest
identity 和 verification generation 读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket
文件)。Go 不重新实现 release verifier;普通 current CDN 只有在 attestation
fresh/ready、分页快照完整且本地只读路径检查都允许时才分发。
- `/v1/releases``/v1/distribution` 和受保护的 `/admin/releases/status`
@@ -270,6 +270,14 @@ distribution 查询只读取该文件、当前 manifest 的内容 identity 和
`destination_index`,再校验目标文件的 size/BLAKE3;不会为了定位一个 destination
重做完整 mapping canonicalization 或遍历其他资源。
`official-distribution-attestation.json` 是 Rust full local verification 的结果。发布时
文件先写入 `.staging/<id>`,但其中的 `resource_root` 永远记录最终的
`versions/<id>` canonical root;随后 staging 目录原子重命名并切换 `current`,不会因为
重命名再次增加 verification generation。周期性 current 验证、显式 verify/repair 和新
release 发布都会写入新的 generation;失败会写入 `ready=false``integrity_status=invalid`
`verified_at=null` 的新结果。`max_age_seconds` 由 Rust watch 的验证周期和失败重试
周期计算,缺失或为 0 的旧结果直接视为不可用,不使用固定兼容 fallback。
---
## 5. 发现与 seed URL 规则(L2
+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 读取当前 official `release.attestation`,再以同一 release/publication/manifest identity 请求 `resource.manifest`,从而跟随 Rust `bat` 切换 `current -> versions/<id>`attestation 过期或分页代际不一致时 fail closed。
不要在生产 env 里设置 `BAT_API_RESOURCE_ROOT``bat-api` 会按 `BAT_API_REFRESH_INTERVAL` 周期通过 RPC 读取当前 official `release.attestation`,再以同一 release/publication/mapping/manifest identity 和 verification generation 请求 `resource.manifest`,从而跟随 Rust `bat` 切换 `current -> versions/<id>`attestation 过期、generation 变化或分页不一致时 fail closed。
### 健康检查
+7 -1
View File
@@ -34,9 +34,15 @@ cargo --version
rustfmt --version
cargo clippy --version
go version
golangci-lint --version # 必须为 2.12.2
```
该 workflow 会用 `GITHUB_SERVER_URL``GITHUB_REPOSITORY``GITHUB_REF``GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的只读格式检查、检查、构建、clippy 和测试,以及 Go API 格式、测试、vet、构建、可选 lint 和文档状态门禁。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。
缺少上述命令、版本不匹配或 `golangci-lint` 不是 2.12.2 都会使 required gate 失败;
`golangci-lint 2.12.2` 是 required gate,不是可选检查。该 workflow 会用
`GITHUB_SERVER_URL``GITHUB_REPOSITORY``GITHUB_REF``GITHUB_SHA` 手动 `git fetch`
当前提交,再执行 Rust workspace 的只读格式检查、检查、构建、clippy 和测试,以及通过
`make check-go-format` 执行的 Go 格式、测试、vet、构建、2.12.2 lint 和文档状态门禁。
这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。
#### Docker
```bash
+6 -4
View File
@@ -104,7 +104,7 @@ 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` | 已实现 | `{ "release_id": "...", "expected_publication_identity": "...", "expected_manifest_identity": "...", "offset": 0, "limit": 100 }` | 绑定一个 Rust attested official generation 的 download manifest 分页。 |
| `resource.manifest` | 已实现 | `{ "release_id": "...", "expected_publication_identity": "...", "expected_manifest_identity": "...", "expected_verification_generation": 7, "offset": 0, "limit": 100 }` | 绑定一个 Rust attested official generation 的 download manifest 分页generation 为必需绑定条件,`0` 也不能省略或忽略。 |
| `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` 分页/过滤查询。 |
@@ -120,12 +120,14 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
`1..=1000`,非法参数返回 `BAT-ERR-700002`
`resource.manifest` 的请求必须携带由 `release.attestation` 返回的
`release_id``expected_publication_identity``expected_manifest_identity`
`release_id``expected_publication_identity``expected_manifest_identity`
`expected_verification_generation`
每一页返回 `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
任何一页不一致都会丢弃整个候选快照。
不匹配、attestation 不可用或 generation 改变时拒绝请求;Go 会逐页验证 channel、
这些 identity、generation、manifest version、页 offset/limit、total 和最终 entry
count,任何一页不一致都会丢弃整个候选快照。
`resource.index``entries[]``Resource` JSON,除 `id``local_path`
`entry` 外会包含 `metadata``official_release_id``platform`
+2 -2
View File
@@ -61,7 +61,7 @@
| ID | 约定 |
|---|---|
| F | 版本/清单经 **`bat.sock` JSON-RPC**`--socket`);不读 daemon 内部状态文件 |
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再轻量 **`release.attestation`**,再 catalog/manifestmanifest 请求绑定 attested release/publication/manifest identity |
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再轻量 **`release.attestation`**,再 catalog/manifestmanifest 请求绑定 attested release/publication/mapping/manifest identity 和 verification generation |
| 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 |
@@ -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.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 输出字段 |
| 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 和 verification generation`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` |
+109 -24
View File
@@ -120,8 +120,9 @@ use workflow_commands::{
const EXIT_ERROR: i32 = 1;
const EXIT_LOCKED: i32 = 75;
const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60;
const DEFAULT_ERROR_RETRY_SECONDS: u64 = 60;
const DEFAULT_WATCH_INTERVAL_SECONDS: u64 =
bat_infrastructure::DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS;
const DEFAULT_ERROR_RETRY_SECONDS: u64 = bat_infrastructure::DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS;
const DEFAULT_DAEMON_STATE_DIR: &str = "/tmp/bat-pid";
const DAEMON_PID_FILE: &str = "bat.pid";
const DAEMON_STATUS_FILE: &str = "bat-status.json";
@@ -184,10 +185,13 @@ fn run() -> anyhow::Result<i32> {
} else {
assert_no_live_daemon_output_conflict(&options, "run")?;
let mut logger = ProgressLogger::new(options.progress);
let report =
OfficialUpdateService::new().run_with_progress(&options.config, |event| {
logger.log(event);
})?;
let report = OfficialUpdateService::with_verification_cadence(
options.interval,
options.error_retry_interval,
)
.run_with_progress(&options.config, |event| {
logger.log(event);
})?;
if should_print_status(report.update_status, options.quiet_up_to_date) {
report_output::print_report(options.output_format, &report)?;
}
@@ -717,7 +721,10 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
}
install_shutdown_signal_handlers()?;
clear_shutdown_signal_request();
let service = OfficialUpdateService::new();
let service = OfficialUpdateService::with_verification_cadence(
options.interval,
options.error_retry_interval,
);
let mut logger = ProgressLogger::new(options.progress);
let daemon_state_dir = options.state_dir.clone();
if options.daemon_child {
@@ -747,9 +754,12 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
let registry = registry.clone();
let sync_lock = Arc::clone(&sync_lock);
let control = Arc::clone(control);
let worker_service = service.clone();
thread::Builder::new()
.name("bat-daemon-task-worker".to_string())
.spawn(move || run_task_worker(task_rx, registry, sync_lock, control))?
.spawn(move || {
run_task_worker(task_rx, registry, sync_lock, control, worker_service)
})?
};
let context = DaemonTaskContext {
registry,
@@ -792,6 +802,9 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
}
let mut iteration_config = options.config.clone();
// Watch 是 current release 的唯一周期性本地验证入口,不能被一次性
// 同步命令的 audit_local 选项关闭。
iteration_config.audit_local = true;
if pending_scheduled_force {
iteration_config.force = true;
}
@@ -1167,6 +1180,8 @@ struct ResourceManifestRpcParams {
#[serde(default)]
expected_manifest_identity: Option<String>,
#[serde(default)]
expected_verification_generation: Option<u64>,
#[serde(default)]
offset: usize,
#[serde(default = "default_rpc_manifest_limit")]
limit: usize,
@@ -3027,6 +3042,15 @@ fn build_resource_manifest_report(
record.id
));
}
if params.release_id.is_none()
|| params.expected_publication_identity.is_none()
|| params.expected_manifest_identity.is_none()
|| params.expected_verification_generation.is_none()
{
return Err(anyhow::anyhow!(
"resource.manifest 请求必须同时携带 release_id、publication_identity、manifest_identity 和 verification_generation"
));
}
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)?
@@ -3047,18 +3071,59 @@ fn build_resource_manifest_report(
};
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
let attestation = status_file
.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);
.map(|status| {
build_official_distribution_attestation(&status.resource_output_root).map_err(|error| {
anyhow::anyhow!("读取 resource.manifest attestation 失败:{error}")
})
})
.transpose()?;
if let Some(report) = attestation.as_ref() {
if report.resource_root != record.resource_root.display().to_string() {
return Err(anyhow::anyhow!(
"resource.manifest attestation root 与当前 release 不一致:attestation={} current={}",
report.resource_root,
record.resource_root.display()
));
}
}
let Some(report) = attestation.as_ref() else {
return Err(anyhow::anyhow!(
"resource.manifest 请求缺少当前 Rust attestation"
));
};
let publication_identity = report.publication_identity.clone();
let verification_generation = report.verification_generation;
if !report.available
|| !report.ready
|| report.integrity_status != "verified"
|| report.channel != "official"
{
return Err(anyhow::anyhow!(
"resource.manifest 当前 attestation 不可用:status={} integrity_status={}",
report.status,
report.integrity_status
));
}
if report.release_id != record.id
|| report.publication_identity != publication_identity
|| report.mapping_identity != mapping_identity
|| report.manifest_identity != manifest_identity
{
return Err(anyhow::anyhow!(
"resource.manifest 当前 attestation identity 与 manifest 不一致"
));
}
if params.release_id.as_deref() != Some(record.id.as_str())
|| params.expected_publication_identity.as_deref() != Some(publication_identity.as_str())
|| params.expected_manifest_identity.as_deref() != Some(manifest_identity.as_str())
|| params.expected_verification_generation != Some(verification_generation)
{
return Err(anyhow::anyhow!(
"resource.manifest 请求 identity 或 verification_generation 与当前 attestation 不一致"
));
}
if params
.expected_manifest_identity
.as_deref()
@@ -5378,8 +5443,11 @@ fn run_sync_command_foreground(
config.force = false;
}
let mut logger = ProgressLogger::new(options.progress);
let report =
OfficialUpdateService::new().run_with_progress(&config, |event| logger.log(event))?;
let report = OfficialUpdateService::with_verification_cadence(
options.interval,
options.error_retry_interval,
)
.run_with_progress(&config, |event| logger.log(event))?;
let command_report = CommandReport {
command: command_name,
status: "completed",
@@ -5533,8 +5601,22 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
config.force = false;
let mut logger = ProgressLogger::new(options.progress);
let update_report =
OfficialUpdateService::new().run_with_progress(&config, |event| logger.log(event))?;
let service = OfficialUpdateService::with_verification_cadence(
options.interval,
options.error_retry_interval,
);
let update_report = match service.run_with_progress(&config, |event| logger.log(event)) {
Ok(report) => report,
Err(error) => {
// A verifier error must revoke the previous ready generation
// before the command returns its original failure.
let _ = verify_and_record_official_distribution_attestation(
&config,
service.attestation_max_age_seconds(),
);
return Err(error);
}
};
let verified_resource_root = active_official_resource_root(&config.output_root)?;
let verification = bat_infrastructure::OfficialResourcePullService::with_curl_command(
&verified_resource_root,
@@ -5558,7 +5640,10 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
zip_error: item.zip_error.clone(),
})
.collect::<Vec<_>>();
verify_and_record_official_distribution_attestation(&config)?;
verify_and_record_official_distribution_attestation(
&config,
service.attestation_max_age_seconds(),
)?;
let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate
&& update_report.local_manifest_repair_needed_count == 0
&& verification.is_clean();
+214 -5
View File
@@ -4181,7 +4181,7 @@ fn dispatch_catalog_status_reads_current_snapshot() {
"req-cat-1".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["ok"], true, "{value}");
assert_eq!(value["data"]["available"], true);
assert_eq!(value["data"]["bundle_version"], "bundle-b2");
assert_eq!(value["data"]["status"], "published");
@@ -4405,6 +4405,8 @@ fn catalog_refresh_config_is_dry_run_plan_only() {
#[test]
fn dispatch_resource_manifest_paginates() {
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("output");
@@ -4432,16 +4434,80 @@ fn dispatch_resource_manifest_paginates() {
},
},
});
let mut manifest: bat_infrastructure::OfficialDownloadManifest =
serde_json::from_value(manifest).unwrap();
let mapping_identity = bat_infrastructure::official_distribution_mapping_identity(&manifest);
manifest.distribution_mapping_identity = Some(mapping_identity.clone());
manifest.destination_index = manifest
.entries
.values()
.map(|entry| (entry.destination.clone(), entry.url.clone()))
.collect();
let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
fs::write(
current_dir.join("official-download-manifest.json"),
serde_json::to_vec(&manifest).unwrap(),
&manifest_bytes,
)
.unwrap();
let manifest_identity = blake3::hash(&manifest_bytes).to_hex().to_string();
let publication_identity = format!("odp-v1-{mapping_identity}-{manifest_identity}");
fs::write(
current_dir.join("official-distribution-publication.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"official_release_id": "v-current",
"mapping_identity": mapping_identity,
"manifest_identity": manifest_identity,
"entry_count": 3,
}))
.unwrap(),
)
.unwrap();
fs::write(
current_dir.join("official-distribution-attestation.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"channel": "official",
"official_release_id": "v-current",
"resource_root": current_dir,
"publication_identity": publication_identity,
"mapping_identity": mapping_identity,
"manifest_identity": manifest_identity,
"entry_count": 3,
"integrity_status": "verified",
"status": "ready",
"status_code": "distribution.ready",
"ready": true,
"verification_generation": 1,
"verified_at": unix_seconds_now(),
"max_age_seconds": 7260,
}))
.unwrap(),
)
.unwrap();
symlink(
Path::new("versions").join("v-current"),
output_root.join("current"),
)
.unwrap();
let bound_params = serde_json::json!({
"release_id": "v-current",
"expected_publication_identity": publication_identity,
"expected_manifest_identity": manifest_identity,
"expected_verification_generation": 1,
});
let envelope = dispatch_rpc_method(
&rpc_request(
"resource.manifest",
Some(serde_json::json!({ "offset": 1, "limit": 2 })),
Some(serde_json::json!({
"release_id": bound_params["release_id"],
"expected_publication_identity": bound_params["expected_publication_identity"],
"expected_manifest_identity": bound_params["expected_manifest_identity"],
"expected_verification_generation": bound_params["expected_verification_generation"],
"offset": 1,
"limit": 2,
})),
),
&state_dir,
&new_daemon_control(),
@@ -4463,7 +4529,16 @@ fn dispatch_resource_manifest_paginates() {
// 非法 limit → 参数错误。
let envelope = dispatch_rpc_method(
&rpc_request("resource.manifest", Some(serde_json::json!({ "limit": 0 }))),
&rpc_request(
"resource.manifest",
Some(serde_json::json!({
"release_id": bound_params["release_id"],
"expected_publication_identity": bound_params["expected_publication_identity"],
"expected_manifest_identity": bound_params["expected_manifest_identity"],
"expected_verification_generation": bound_params["expected_verification_generation"],
"limit": 0,
})),
),
&state_dir,
&new_daemon_control(),
&test_task_context(),
@@ -4476,7 +4551,14 @@ fn dispatch_resource_manifest_paginates() {
let envelope = dispatch_rpc_method(
&rpc_request(
"resource.list",
Some(serde_json::json!({ "offset": 2, "limit": 1 })),
Some(serde_json::json!({
"release_id": bound_params["release_id"],
"expected_publication_identity": bound_params["expected_publication_identity"],
"expected_manifest_identity": bound_params["expected_manifest_identity"],
"expected_verification_generation": bound_params["expected_verification_generation"],
"offset": 2,
"limit": 1,
})),
),
&state_dir,
&new_daemon_control(),
@@ -4495,7 +4577,9 @@ fn dispatch_resource_manifest_paginates() {
"resource.manifest",
Some(serde_json::json!({
"release_id": "v-current",
"expected_publication_identity": publication_identity,
"expected_manifest_identity": "wrong-generation",
"expected_verification_generation": 1,
"offset": 0,
"limit": 1,
})),
@@ -4509,6 +4593,131 @@ fn dispatch_resource_manifest_paginates() {
assert_eq!(value["ok"], false);
}
#[test]
fn dispatch_resource_manifest_rejects_previous_verification_generation() {
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("output");
let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None);
let manifest = serde_json::json!({
"version": 1,
"entries": {
"https://prod-clientpatch.bluearchiveyostar.com/a": {
"url": "https://prod-clientpatch.bluearchiveyostar.com/a",
"destination": "a",
"bytes": 1,
"blake3": blake3::hash(b"a").to_hex().to_string(),
},
"https://prod-clientpatch.bluearchiveyostar.com/b": {
"url": "https://prod-clientpatch.bluearchiveyostar.com/b",
"destination": "b",
"bytes": 1,
"blake3": blake3::hash(b"b").to_hex().to_string(),
},
},
});
let mut manifest: bat_infrastructure::OfficialDownloadManifest =
serde_json::from_value(manifest).unwrap();
manifest.distribution_mapping_identity = Some(
bat_infrastructure::official_distribution_mapping_identity(&manifest),
);
manifest.destination_index = manifest
.entries
.values()
.map(|entry| (entry.destination.clone(), entry.url.clone()))
.collect();
let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
fs::write(
current_dir.join("official-download-manifest.json"),
&manifest_bytes,
)
.unwrap();
fs::write(current_dir.join("a"), b"a").unwrap();
fs::write(current_dir.join("b"), b"b").unwrap();
symlink(
Path::new("versions").join("v-current"),
output_root.join("current"),
)
.unwrap();
let mapping_identity = bat_infrastructure::official_distribution_mapping_identity(&manifest);
let manifest_identity = blake3::hash(&manifest_bytes).to_hex().to_string();
let publication_identity = format!("odp-v1-{mapping_identity}-{manifest_identity}");
let write_attestation = |generation: u64| {
fs::write(
current_dir.join("official-distribution-publication.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"official_release_id": "v-current",
"mapping_identity": mapping_identity,
"manifest_identity": manifest_identity,
"entry_count": 2,
}))
.unwrap(),
)
.unwrap();
fs::write(
current_dir.join("official-distribution-attestation.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"channel": "official",
"official_release_id": "v-current",
"resource_root": current_dir,
"publication_identity": publication_identity,
"mapping_identity": mapping_identity,
"manifest_identity": manifest_identity,
"entry_count": 2,
"integrity_status": "verified",
"status": "ready",
"status_code": "distribution.ready",
"ready": true,
"verification_generation": generation,
"verified_at": unix_seconds_now(),
"max_age_seconds": 7260,
}))
.unwrap(),
)
.unwrap();
};
write_attestation(4);
let bound_params = serde_json::json!({
"release_id": "v-current",
"expected_publication_identity": publication_identity,
"expected_manifest_identity": manifest_identity,
"expected_verification_generation": 4,
"offset": 0,
"limit": 1,
});
let envelope = dispatch_rpc_method(
&rpc_request("resource.manifest", Some(bound_params.clone())),
&state_dir,
&new_daemon_control(),
&test_task_context(),
"req-generation-1".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["generation"], 4);
assert_eq!(
value["data"]["resource_root"],
current_dir.to_string_lossy().as_ref()
);
write_attestation(5);
let envelope = dispatch_rpc_method(
&rpc_request("resource.manifest", Some(bound_params)),
&state_dir,
&new_daemon_control(),
&test_task_context(),
"req-generation-2".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], false);
}
fn write_resource_index_fixture(repository_path: &Path) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
+23 -7
View File
@@ -537,8 +537,8 @@ pub(super) fn run_task_worker(
registry: TaskRegistry,
sync_lock: Arc<Mutex<()>>,
control: DaemonControl,
service: OfficialUpdateService,
) {
let service = OfficialUpdateService::new();
for job in receiver {
registry.update(&job.id, |record| {
record.status = "running";
@@ -602,15 +602,31 @@ pub(super) fn run_task_worker(
},
)
};
run_result
.and_then(|report| {
if job.kind == TaskKind::Verify {
let run_result = if job.kind == TaskKind::Verify {
match run_result {
Ok(report) => {
bat_infrastructure::verify_and_record_official_distribution_attestation(
&job.config,
)?;
service.attestation_max_age_seconds(),
)
.map(|_| report)
}
Ok(report)
})
Err(error) => {
// If the verifier failed before returning its report
// (for example, a malformed manifest), make a best
// effort to revoke the previous ready generation.
let _ =
bat_infrastructure::verify_and_record_official_distribution_attestation(
&job.config,
service.attestation_max_age_seconds(),
);
Err(error)
}
}
} else {
run_result
};
run_result
.map(|report| serde_json::to_value(&report).map_err(anyhow::Error::from))
.and_then(|result| result)
};
+4 -3
View File
@@ -80,7 +80,8 @@ pub use official_changes::{
OFFICIAL_RESOURCE_CHANGES_VERSION,
};
pub use official_download::{
official_distribution_mapping_identity, read_cas_reuse_reference_manifest_at,
official_distribution_mapping_identity, official_distribution_max_age_for_durations,
official_distribution_max_age_seconds, read_cas_reuse_reference_manifest_at,
read_download_manifest_at, release_cas_reuse_references, DownloadError,
OfficialCasReuseReferenceManifest, OfficialDistributionAttestation, OfficialDownloadManifest,
OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem,
@@ -89,9 +90,9 @@ pub use official_download::{
OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress,
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
OfficialResourcePullStatus, OfficialResourceReuseWarning, OfficialResourceVerification,
DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS, DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS,
OFFICIAL_CAS_REUSE_REFERENCES_FILE, OFFICIAL_DISTRIBUTION_ATTESTATION_FILE,
OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS, OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION,
OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION, OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
};
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
pub use official_launcher::{
+125 -18
View File
@@ -27,7 +27,7 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。
///
@@ -75,8 +75,10 @@ pub const OFFICIAL_DISTRIBUTION_PUBLICATION_FILE: &str = "official-distribution-
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;
/// Default interval used by the Rust official watch verifier.
pub const DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS: u64 = 60 * 60;
/// Default retry interval used after an official watch failure.
pub const DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS: u64 = 60;
/// 记录一个已发布官方 release 获取的 CAS 引用。
pub const OFFICIAL_CAS_REUSE_REFERENCES_FILE: &str = "official-cas-reuse-references.json";
const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1;
@@ -87,6 +89,40 @@ const CAS_OWNER_SCOPE_FILE: &str = ".cas-owner-scope";
const CAS_OWNER_SCOPE_VERSION: u32 = 1;
static CAS_OWNERSHIP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
/// Returns the only freshness policy used for official distribution
/// attestations.
///
/// The window covers two complete verification intervals plus one retry
/// interval, so a healthy attestation cannot remain authoritative after the
/// verifier has missed an entire cycle and its retry.
pub const fn official_distribution_max_age_seconds(
verification_interval_seconds: u64,
error_retry_seconds: u64,
) -> u64 {
verification_interval_seconds
.saturating_mul(2)
.saturating_add(error_retry_seconds)
}
/// Converts watch durations to the seconds used by the persisted freshness
/// policy, rounding partial seconds up so a non-zero duration never becomes a
/// zero-second policy.
pub fn official_distribution_max_age_for_durations(
verification_interval: Duration,
error_retry: Duration,
) -> u64 {
fn ceil_seconds(duration: Duration) -> u64 {
duration
.as_secs()
.saturating_add(u64::from(duration.subsec_nanos() != 0))
}
official_distribution_max_age_seconds(
ceil_seconds(verification_interval),
ceil_seconds(error_retry),
)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CasOwnerScopeState {
version: u32,
@@ -664,10 +700,12 @@ pub(crate) fn read_official_distribution_attestation_at(
/// passed; this function itself never turns a partial audit into a healthy
/// result.
pub(crate) fn write_official_distribution_attestation_at(
release_root: &Path,
storage_root: &Path,
canonical_resource_root: &Path,
official_release_id: &str,
integrity_status: &str,
diagnostics: Vec<String>,
max_age_seconds: u64,
mut diagnostics: Vec<String>,
) -> Result<OfficialDistributionAttestation, String> {
if !matches!(
integrity_status,
@@ -677,23 +715,80 @@ pub(crate) fn write_official_distribution_attestation_at(
"不支持的官方 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)?
if max_age_seconds == 0 {
return Err("官方 distribution attestation freshness window 必须大于 0".to_string());
}
ensure_safe_directory_path(storage_root, "官方 distribution attestation 存储目录")?;
ensure_safe_directory_path(
canonical_resource_root,
"官方 distribution canonical 根目录",
)?;
ensure_path_within_root(&ownership_scope_root(storage_root), canonical_resource_root)?;
let previous = match read_official_distribution_attestation_at(storage_root) {
Ok(previous) => previous,
Err(error) if integrity_status == "invalid" => {
diagnostics.push(format!(
"上一代 distribution attestation 不可读取,重新记录 invalid{error}"
));
None
}
Err(error) => return Err(error),
};
let previous_generation = previous
.as_ref()
.map(|previous| previous.verification_generation)
.unwrap_or(0);
let anchor =
match verify_official_distribution_publication_at(storage_root, official_release_id) {
Ok(Some(anchor)) => anchor,
Ok(None) if integrity_status == "invalid" => {
let Some(previous) = previous.as_ref() else {
return Err(format!(
"官方 distribution attestation 缺少 publication anchor{}",
storage_root.display()
));
};
diagnostics
.push("publication anchor 缺失,沿用上一代 identity 记录 invalid".to_string());
OfficialDistributionPublicationAnchor {
version: OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION,
official_release_id: official_release_id.to_string(),
mapping_identity: previous.mapping_identity.clone(),
manifest_identity: previous.manifest_identity.clone(),
entry_count: previous.entry_count,
}
}
Ok(None) => {
return Err(format!(
"官方 distribution attestation 缺少 publication anchor{}",
storage_root.display()
));
}
Err(error) if integrity_status == "invalid" => {
let Some(previous) = previous.as_ref() else {
return Err(format!(
"无法记录 invalid distribution attestation{error}"
));
};
diagnostics.push(format!(
"publication anchor 验证失败,沿用上一代 identity{error}"
));
OfficialDistributionPublicationAnchor {
version: OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION,
official_release_id: official_release_id.to_string(),
mapping_identity: previous.mapping_identity.clone(),
manifest_identity: previous.manifest_identity.clone(),
entry_count: previous.entry_count,
}
}
Err(error) => return Err(error),
};
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(),
resource_root: canonical_resource_root.to_path_buf(),
publication_identity: official_distribution_publication_identity(&anchor),
mapping_identity: anchor.mapping_identity,
manifest_identity: anchor.manifest_identity,
@@ -704,11 +799,11 @@ pub(crate) fn write_official_distribution_attestation_at(
ready: integrity_status == "verified",
verification_generation: previous_generation.saturating_add(1),
verified_at,
max_age_seconds: OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS,
max_age_seconds,
diagnostics,
};
let path = release_root.join(OFFICIAL_DISTRIBUTION_ATTESTATION_FILE);
ensure_safe_file_target(release_root, &path, "官方 distribution attestation")?;
let path = storage_root.join(OFFICIAL_DISTRIBUTION_ATTESTATION_FILE);
ensure_safe_file_target(storage_root, &path, "官方 distribution attestation")?;
let bytes = serde_json::to_vec_pretty(&attestation)
.map_err(|error| format!("序列化官方 distribution attestation 失败:{error}"))?;
write_file_atomic(
@@ -4036,6 +4131,18 @@ exit 22
}
}
#[test]
fn official_distribution_freshness_policy_is_cadence_bound() {
assert_eq!(official_distribution_max_age_seconds(3600, 60), 7260);
assert_eq!(
official_distribution_max_age_for_durations(
Duration::from_millis(1500),
Duration::from_millis(1),
),
5
);
}
#[test]
fn official_distribution_mapping_identity_is_deterministic_and_sensitive() {
let entries = [
+353 -34
View File
@@ -14,9 +14,9 @@ use crate::official_changes::{
OfficialResourceChangeSummary,
};
use crate::official_download::{
write_official_distribution_attestation_at, write_official_distribution_publication_anchor_at,
OfficialDistributionAttestation, OFFICIAL_CAS_REUSE_REFERENCES_FILE,
OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
official_distribution_max_age_for_durations, 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::{
resolve_game_main_config_source, OfficialGameMainConfigSelectedSource,
@@ -1213,13 +1213,48 @@ impl OfficialPublishLayout {
}
/// Official update runner.
#[derive(Debug, Clone, Default)]
pub struct OfficialUpdateService;
#[derive(Debug, Clone)]
pub struct OfficialUpdateService {
attestation_max_age_seconds: u64,
}
impl Default for OfficialUpdateService {
fn default() -> Self {
Self::new()
}
}
impl OfficialUpdateService {
/// Creates an official update runner.
pub fn new() -> Self {
Self
Self {
attestation_max_age_seconds: official_distribution_max_age_for_durations(
std::time::Duration::from_secs(
crate::official_download::DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS,
),
std::time::Duration::from_secs(
crate::official_download::DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS,
),
),
}
}
/// Creates an update runner using the daemon's actual watch cadence.
pub fn with_verification_cadence(
verification_interval: std::time::Duration,
error_retry: std::time::Duration,
) -> Self {
Self {
attestation_max_age_seconds: official_distribution_max_age_for_durations(
verification_interval,
error_retry,
),
}
}
/// Returns the freshness window persisted with each attestation.
pub fn attestation_max_age_seconds(&self) -> u64 {
self.attestation_max_age_seconds
}
/// Executes one official update run.
@@ -1607,11 +1642,24 @@ impl OfficialUpdateService {
fetcher.download_manifest_path().display()
),
));
Some(
fetcher
.audit_local_manifest(&pull_plan)
.map_err(anyhow::Error::msg)?,
)
match fetcher.audit_local_manifest(&pull_plan) {
Ok(audit) => Some(audit),
Err(error) => {
if !config.dry_run && has_current_pointer {
let release_id = version_id_from_path(&active_resource_root)
.unwrap_or_else(|| fallback_version_id(&current_update_snapshot));
let _ = write_official_distribution_attestation_at(
&active_resource_root,
&active_resource_root,
&release_id,
"invalid",
self.attestation_max_age_seconds,
vec![format!("本地 manifest 审计失败:{error}")],
);
}
return Err(anyhow::Error::msg(error));
}
}
} else if config.audit_local {
progress(OfficialUpdateProgress::new(
"audit",
@@ -1673,6 +1721,31 @@ impl OfficialUpdateService {
check_shutdown_requested(&mut should_cancel)?;
let active_release_id = version_id_from_path(&active_resource_root)
.unwrap_or_else(|| fallback_version_id(&current_update_snapshot));
let mut local_attestation_invalidated = false;
if !config.dry_run
&& has_current_pointer
&& local_audit.as_ref().is_some_and(|audit| !audit.is_clean())
{
let diagnostics = local_audit
.as_ref()
.map(|audit| {
vec![format!(
"本地 manifest 审计失败:{} 项需要修复",
audit.repair_needed_count()
)]
})
.unwrap_or_default();
write_official_distribution_attestation_at(
&active_resource_root,
&active_resource_root,
&active_release_id,
"invalid",
self.attestation_max_age_seconds,
diagnostics,
)
.map_err(anyhow::Error::msg)?;
local_attestation_invalidated = true;
}
let localized_info = localized_release_info_for(config, Some(active_release_id.as_str()));
let mut report = OfficialUpdateReport {
update_status: if should_download {
@@ -1786,27 +1859,31 @@ 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)?;
if !local_attestation_invalidated {
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_resource_root,
&active_release_id,
integrity_status,
self.attestation_max_age_seconds,
diagnostics,
)
.map_err(anyhow::Error::msg)?;
}
}
let active_launcher_bootstrap_path =
active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE);
@@ -2040,8 +2117,10 @@ impl OfficialUpdateService {
.map_err(anyhow::Error::msg)?;
write_official_distribution_attestation_at(
&publish_plan.staging_path,
&publish_plan.version_path,
&publish_plan.id,
"verified",
self.attestation_max_age_seconds,
Vec::new(),
)
.map_err(anyhow::Error::msg)?;
@@ -2218,6 +2297,7 @@ impl OfficialUpdateService {
/// resource artifacts.
pub fn verify_and_record_official_distribution_attestation(
config: &OfficialUpdateConfig,
max_age_seconds: u64,
) -> anyhow::Result<OfficialDistributionAttestation> {
let version_state = read_version_state(&config.version_state_path())?
.ok_or_else(|| anyhow::anyhow!("官方版本状态不存在,无法记录 distribution attestation"))?;
@@ -2237,11 +2317,27 @@ pub fn verify_and_record_official_distribution_attestation(
record.resource_root.display()
));
}
let verification =
let verification_result =
OfficialResourcePullService::with_curl_command(&resource_root, &config.curl_command)
.with_proxy_config(config.curl_proxy.clone())
.verify_local_download_manifest()
.verify_local_download_manifest();
let verification = match verification_result {
Ok(verification) => verification,
Err(error) => {
write_official_distribution_attestation_at(
&resource_root,
&resource_root,
&record.id,
"invalid",
max_age_seconds,
vec![format!("本地 manifest 验证失败:{error}")],
)
.map_err(anyhow::Error::msg)?;
return Err(anyhow::anyhow!(
"本地 manifest 验证失败,已立即使当前 distribution attestation 失效:{error}"
));
}
};
let diagnostics = verification
.items
.iter()
@@ -2254,9 +2350,11 @@ pub fn verify_and_record_official_distribution_attestation(
"invalid"
};
write_official_distribution_attestation_at(
&resource_root,
&resource_root,
&record.id,
integrity_status,
max_age_seconds,
diagnostics,
)
.map_err(anyhow::Error::msg)
@@ -4977,6 +5075,227 @@ mod tests {
assert_eq!(read_version_state(&path).unwrap(), Some(state));
}
#[cfg(unix)]
#[test]
fn publication_attestation_keeps_canonical_root_across_staging_rename() {
use crate::official_download::{
official_distribution_max_age_seconds, write_official_distribution_attestation_at,
write_official_distribution_publication_anchor_at, OfficialDownloadManifest,
OfficialDownloadManifestEntry,
};
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("official");
let layout = OfficialPublishLayout::new(&root);
let staging = layout.staging_dir.join("release-a");
let version = layout.versions_dir.join("release-a");
fs::create_dir_all(&staging).unwrap();
fs::create_dir_all(&layout.versions_dir).unwrap();
let payload = b"official";
fs::write(staging.join("data.bin"), payload).unwrap();
let url = "https://example.invalid/data.bin".to_string();
let mut manifest = OfficialDownloadManifest {
entries: [(
url.clone(),
OfficialDownloadManifestEntry {
url: url.clone(),
destination: "data.bin".to_string(),
bytes: payload.len() as u64,
blake3: blake3::hash(payload).to_hex().to_string(),
},
)]
.into_iter()
.collect(),
..OfficialDownloadManifest::default()
};
manifest.destination_index = [("data.bin".to_string(), url)].into_iter().collect();
manifest.distribution_mapping_identity =
Some(crate::official_download::official_distribution_mapping_identity(&manifest));
fs::write(
staging.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE),
serde_json::to_vec(&manifest).unwrap(),
)
.unwrap();
write_official_distribution_publication_anchor_at(&staging, "release-a").unwrap();
let max_age = official_distribution_max_age_seconds(3600, 60);
let before_publish = write_official_distribution_attestation_at(
&staging,
&version,
"release-a",
"verified",
max_age,
Vec::new(),
)
.unwrap();
assert_eq!(before_publish.resource_root, version);
assert_eq!(before_publish.verification_generation, 1);
let plan = OfficialPublishPlan {
id: "release-a".to_string(),
staging_path: staging,
version_path: version.clone(),
reuse_existing_staging: false,
};
let published = layout.publish(&plan).unwrap();
assert_eq!(published, version);
let after_rename =
crate::official_download::read_official_distribution_attestation_at(&version)
.unwrap()
.unwrap();
assert_eq!(after_rename.resource_root, version);
assert_eq!(
after_rename.verification_generation,
before_publish.verification_generation
);
let snapshot_path = version.join(OFFICIAL_SYNC_SNAPSHOT_FILE);
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
write_snapshot(&snapshot_path, &snapshot).unwrap();
let state = OfficialVersionState {
current_completed_version: Some(OfficialVersionRecord {
id: "release-a".to_string(),
app_version: snapshot.app_version.clone(),
bundle_version: snapshot.bundle_version.clone(),
addressables_root: snapshot.addressables_root.clone(),
resource_root: version.clone(),
snapshot_path,
staging_path: None,
version_path: Some(version.clone()),
started_unix_seconds: Some(1),
completed_unix_seconds: Some(2),
}),
..OfficialVersionState::default()
};
write_version_state(&root.join(OFFICIAL_VERSION_STATE_FILE), &state).unwrap();
assert_eq!(
fs::read_link(root.join(OFFICIAL_CURRENT_LINK)).unwrap(),
Path::new(OFFICIAL_VERSIONS_DIR).join("release-a")
);
assert_eq!(
state
.current_completed_version
.as_ref()
.unwrap()
.resource_root,
after_rename.resource_root
);
let report = crate::release_ops::build_official_distribution_attestation(&root).unwrap();
assert!(report.available);
assert!(report.ready);
assert_eq!(report.release_id, "release-a");
assert_eq!(report.resource_root, version.display().to_string());
assert_eq!(report.verification_generation, 1);
// Keep this assertion explicit: current is the only pointer used by
// the read path, and the attestation never stores the staging path.
assert!(!after_rename.resource_root.starts_with(layout.staging_dir));
}
#[test]
fn failed_local_verification_invalidates_previous_ready_generation() {
use crate::official_download::{
official_distribution_max_age_seconds, write_official_distribution_attestation_at,
write_official_distribution_publication_anchor_at, OfficialDownloadManifest,
OfficialDownloadManifestEntry,
};
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("official");
let version = root.join(OFFICIAL_VERSIONS_DIR).join("release-a");
fs::create_dir_all(&version).unwrap();
let payload = b"official";
let url = "https://example.invalid/data.bin".to_string();
let mut manifest = OfficialDownloadManifest {
entries: [(
url.clone(),
OfficialDownloadManifestEntry {
url,
destination: "data.bin".to_string(),
bytes: payload.len() as u64,
blake3: blake3::hash(payload).to_hex().to_string(),
},
)]
.into_iter()
.collect(),
..OfficialDownloadManifest::default()
};
manifest.destination_index = [(
"data.bin".to_string(),
"https://example.invalid/data.bin".to_string(),
)]
.into_iter()
.collect();
manifest.distribution_mapping_identity =
Some(crate::official_download::official_distribution_mapping_identity(&manifest));
fs::write(
version.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE),
serde_json::to_vec(&manifest).unwrap(),
)
.unwrap();
fs::write(version.join("data.bin"), payload).unwrap();
write_official_distribution_publication_anchor_at(&version, "release-a").unwrap();
let max_age = official_distribution_max_age_seconds(3600, 60);
let initial = write_official_distribution_attestation_at(
&version,
&version,
"release-a",
"verified",
max_age,
Vec::new(),
)
.unwrap();
symlink(
Path::new(OFFICIAL_VERSIONS_DIR).join("release-a"),
root.join(OFFICIAL_CURRENT_LINK),
)
.unwrap();
let snapshot_path = version.join(OFFICIAL_SYNC_SNAPSHOT_FILE);
write_snapshot(
&snapshot_path,
&OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None),
)
.unwrap();
write_version_state(
&root.join(OFFICIAL_VERSION_STATE_FILE),
&OfficialVersionState {
current_completed_version: Some(OfficialVersionRecord {
id: "release-a".to_string(),
app_version: "app".to_string(),
bundle_version: None,
addressables_root: "root".to_string(),
resource_root: version.clone(),
snapshot_path,
staging_path: None,
version_path: Some(version.clone()),
started_unix_seconds: Some(1),
completed_unix_seconds: Some(2),
}),
..OfficialVersionState::default()
},
)
.unwrap();
fs::write(version.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE), b"{malformed").unwrap();
let config = OfficialUpdateConfig {
output_root: root,
..OfficialUpdateConfig::default()
};
assert!(verify_and_record_official_distribution_attestation(&config, max_age).is_err());
let invalid = crate::official_download::read_official_distribution_attestation_at(&version)
.unwrap()
.unwrap();
assert_eq!(
invalid.verification_generation,
initial.verification_generation + 1
);
assert_eq!(invalid.integrity_status, "invalid");
assert!(!invalid.ready);
assert!(invalid.verified_at.is_none());
}
#[test]
fn version_state_tracks_in_progress_success_and_failure() {
let temp = tempfile::TempDir::new().unwrap();
+68 -8
View File
@@ -15,7 +15,6 @@ use crate::official_download::{
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::{
@@ -377,7 +376,7 @@ pub fn build_official_distribution_attestation(
ready: false,
verification_generation: 0,
verified_at: None,
max_age_seconds: OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS,
max_age_seconds: 0,
diagnostics: vec!["没有当前已发布官方 release".to_string()],
});
};
@@ -397,15 +396,17 @@ pub fn build_official_distribution_attestation(
ready: false,
verification_generation: 0,
verified_at: None,
max_age_seconds: OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS,
max_age_seconds: 0,
diagnostics: Vec::new(),
};
let mut structural_invalid = false;
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()) {
structural_invalid = true;
report.integrity_status = "invalid".to_string();
report.status = "invalid".to_string();
report.status_code = "distribution.attestation_invalid".to_string();
@@ -415,6 +416,7 @@ pub fn build_official_distribution_attestation(
));
}
if let Err(error) = ensure_path_within_root(official_root, &record.resource_root) {
structural_invalid = true;
report.integrity_status = "invalid".to_string();
report.status = "invalid".to_string();
report.status_code = "distribution.attestation_invalid".to_string();
@@ -423,6 +425,7 @@ pub fn build_official_distribution_attestation(
if let Err(error) =
ensure_safe_directory_path(&record.resource_root, "当前官方 distribution 根目录")
{
structural_invalid = true;
report.integrity_status = "invalid".to_string();
report.status = "invalid".to_string();
report.status_code = "distribution.attestation_invalid".to_string();
@@ -478,13 +481,17 @@ pub fn build_official_distribution_attestation(
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.max_age_seconds = attestation.max_age_seconds;
report.diagnostics.extend(attestation.diagnostics);
if structural_invalid {
report.integrity_status = "invalid".to_string();
report.status = "invalid".to_string();
report.status_code = "distribution.attestation_invalid".to_string();
report.ready = false;
return Ok(report);
}
let identity_matches = anchor.as_ref().is_some_and(|anchor| {
attestation.channel == "official"
&& attestation.official_release_id == record.id
@@ -506,6 +513,28 @@ pub fn build_official_distribution_attestation(
return Ok(report);
}
if report.max_age_seconds == 0 {
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 缺失或为 0".to_string());
return Ok(report);
}
if report.verification_generation == 0 {
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 verification generation 缺失或为 0".to_string());
return Ok(report);
}
if report.integrity_status == "verified" {
let fresh = report.verified_at.is_some_and(|verified_at| {
let now = SystemTime::now()
@@ -2049,9 +2078,11 @@ mod tests {
.unwrap();
crate::official_download::write_official_distribution_attestation_at(
&version,
&version,
"official-v1",
"verified",
crate::official_download::official_distribution_max_age_seconds(3600, 60),
Vec::new(),
)
.unwrap();
@@ -2061,6 +2092,22 @@ mod tests {
assert_eq!(healthy.release_id, "official-v1");
assert!(healthy.verification_generation > 0);
fs::remove_file(official_root.join(OFFICIAL_CURRENT_LINK)).unwrap();
symlink(
Path::new(OFFICIAL_VERSIONS_DIR).join("wrong-release"),
official_root.join(OFFICIAL_CURRENT_LINK),
)
.unwrap();
let pointer_mismatch = build_official_distribution_attestation(&official_root).unwrap();
assert!(!pointer_mismatch.ready);
assert_eq!(pointer_mismatch.integrity_status, "invalid");
fs::remove_file(official_root.join(OFFICIAL_CURRENT_LINK)).unwrap();
symlink(
Path::new(OFFICIAL_VERSIONS_DIR).join("official-v1"),
official_root.join(OFFICIAL_CURRENT_LINK),
)
.unwrap();
// 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();
@@ -2068,9 +2115,11 @@ mod tests {
assert!(still_authorized.ready);
crate::official_download::write_official_distribution_attestation_at(
&version,
&version,
"official-v1",
"invalid",
crate::official_download::official_distribution_max_age_seconds(3600, 60),
vec!["other.bin: size_or_hash_mismatch".to_string()],
)
.unwrap();
@@ -2096,6 +2145,17 @@ mod tests {
let stale = build_official_distribution_attestation(&official_root).unwrap();
assert!(!stale.ready);
assert_eq!(stale.integrity_status, "stale");
fresh.verified_at = Some(1);
fresh.max_age_seconds = 0;
fs::write(
version.join(crate::OFFICIAL_DISTRIBUTION_ATTESTATION_FILE),
serde_json::to_vec(&fresh).unwrap(),
)
.unwrap();
let zero_window = build_official_distribution_attestation(&official_root).unwrap();
assert!(!zero_window.ready);
assert_eq!(zero_window.integrity_status, "stale");
}
#[test]
+10 -1
View File
@@ -279,7 +279,7 @@ func TestRefreshCurrentReleaseHealthTransitionsAndClearsFailure(t *testing.T) {
t.Fatal(err)
}
if !server.index().Summary().Ready {
t.Fatal("initial release is not ready")
t.Fatalf("initial release is not ready: %+v", server.index().Summary())
}
get := func() *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
@@ -917,6 +917,10 @@ func (f *fakeBackend) ReleaseAttestation(ctx context.Context) (*backendrpc.Distr
break
}
}
if integrity == "valid" {
integrity = "verified"
}
verifiedAt := uint64(time.Now().Unix())
return &backendrpc.DistributionAttestation{
Available: true,
Channel: "official",
@@ -936,6 +940,8 @@ func (f *fakeBackend) ReleaseAttestation(ctx context.Context) (*backendrpc.Distr
StatusCode: releaseStatus.StatusCode,
Ready: releaseStatus.OfficialDistributionReady,
VerificationGeneration: 1,
VerifiedAt: &verifiedAt,
MaxAgeSeconds: 7260,
}, nil
}
func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
@@ -953,6 +959,9 @@ func (f *fakeBackend) ResourceManifest(ctx context.Context, params backendrpc.Re
}
f.manifestParams = append(f.manifestParams, params)
page := *f.manifest
if page.Channel == "" {
page.Channel = "official"
}
if page.ReleaseID == "" {
page.ReleaseID = params.ReleaseID
}
+1
View File
@@ -99,6 +99,7 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
attestation.ReleaseID != "${VERSION_ID}" ||
attestation.ManifestIdentity != "${MANIFEST_IDENTITY}" ||
attestation.VerificationGeneration != 7 ||
attestation.MaxAgeSeconds != 7260 ||
attestation.VerifiedAt == nil || *attestation.VerifiedAt != 1000 {
t.Fatalf("attestation=%+v", attestation)
}
+45 -1
View File
@@ -4,11 +4,13 @@ import (
"context"
"encoding/json"
"testing"
"time"
"bat-api/internal/backendrpc"
)
func testAttestation(root, releaseID, integrity string, ready bool) *backendrpc.DistributionAttestation {
verifiedAt := uint64(time.Now().Unix())
return &backendrpc.DistributionAttestation{
Available: true,
Channel: "official",
@@ -23,6 +25,8 @@ func testAttestation(root, releaseID, integrity string, ready bool) *backendrpc.
StatusCode: "distribution." + integrity,
Ready: ready,
VerificationGeneration: 4,
VerifiedAt: &verifiedAt,
MaxAgeSeconds: 7260,
}
}
@@ -85,10 +89,30 @@ func TestFetchAllManifestEntriesRejectsMixedPages(t *testing.T) {
name: "manifest identity",
mutate: func(page *backendrpc.ResourceManifestPage) { page.ManifestIdentity = "manifest-b" },
},
{
name: "generation",
mutate: func(page *backendrpc.ResourceManifestPage) { page.Generation = 5 },
},
{
name: "publication identity",
mutate: func(page *backendrpc.ResourceManifestPage) { page.PublicationIdentity = "publication-b" },
},
{
name: "mapping identity",
mutate: func(page *backendrpc.ResourceManifestPage) { page.MappingIdentity = "mapping-b" },
},
{
name: "manifest version",
mutate: func(page *backendrpc.ResourceManifestPage) { page.ManifestVersion = 2 },
},
{
name: "total",
mutate: func(page *backendrpc.ResourceManifestPage) { page.TotalEntries = 4 },
},
{
name: "limit",
mutate: func(page *backendrpc.ResourceManifestPage) { page.Limit = 1 },
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -134,7 +158,8 @@ func TestFetchAllManifestEntriesAcceptsMatchingGeneration(t *testing.T) {
}
if len(backend.params) != 2 ||
backend.params[1].ReleaseID != attestation.ReleaseID ||
backend.params[1].ExpectedManifestIdentity != attestation.ManifestIdentity {
backend.params[1].ExpectedManifestIdentity != attestation.ManifestIdentity ||
backend.params[1].ExpectedVerificationGeneration != attestation.VerificationGeneration {
t.Fatalf("params=%+v", backend.params)
}
}
@@ -239,3 +264,22 @@ func TestStaleOrInvalidMatchingAttestationNeverReadiesIndex(t *testing.T) {
})
}
}
func TestExpiredReadyAttestationNeverReadiesIndex(t *testing.T) {
root := fixtureRoot(t)
backend := fixtureRPCBackend(t, root)
attestation := testAttestation(root, "official-fixture", "verified", true)
expired := uint64(time.Now().Unix()) - attestation.MaxAgeSeconds - 1
attestation.VerifiedAt = &expired
backend.attestation = attestation
result, err := DiscoverAndIndex(context.Background(), backend, "")
if err != nil {
t.Fatal(err)
}
if result.Index == nil || result.Index.Summary().Ready {
t.Fatalf("expired attestation unexpectedly ready: %+v", result.Index.Summary())
}
if len(backend.manifestParams) != 0 {
t.Fatalf("manifest should not be fetched for expired attestation: %+v", backend.manifestParams)
}
}
+5 -1
View File
@@ -61,6 +61,7 @@ type GameMainConfigSummary struct {
// 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 {
Available bool `json:"available"`
Ready bool `json:"ready"`
Source string `json:"source"`
Channel string `json:"channel,omitempty"`
@@ -72,6 +73,7 @@ type DistributionHealth struct {
EntryCount int `json:"entry_count,omitempty"`
VerificationGeneration uint64 `json:"verification_generation,omitempty"`
VerifiedAt *uint64 `json:"verified_at,omitempty"`
MaxAgeSeconds uint64 `json:"max_age_seconds,omitempty"`
Status string `json:"status,omitempty"`
StatusCode string `json:"status_code,omitempty"`
IntegrityStatus string `json:"integrity_status,omitempty"`
@@ -130,7 +132,8 @@ func (idx *ReleaseIndex) Summary() ReleaseSummary {
if distribution.Source == "" {
distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest"
} else {
distribution.Ready = distribution.Ready &&
distribution.Ready = distribution.Available &&
distribution.Ready &&
localComplete &&
(idx.Source != "rpc" ||
(distribution.ManifestIdentity != "" &&
@@ -310,6 +313,7 @@ func LoadIndexFromResourceRoot(resourceRoot string) (*ReleaseIndex, error) {
manifest.Version,
entries,
DistributionHealth{
Available: true,
Ready: true,
Source: "resource_root_override",
Status: "ready",
+65 -13
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"time"
"bat-api/internal/backendrpc"
)
@@ -417,8 +418,17 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
}
out.Attestation = attestation
out.Distribution = rustAttestationHealth(attestation)
if !attestation.Available {
return emptyRPCResult(out, nil, "Rust current official distribution attestation is unavailable"), nil
if !attestation.Available ||
!attestation.Ready ||
attestation.Channel != "official" ||
attestation.IntegrityStatus != "verified" ||
attestation.VerificationGeneration == 0 ||
!attestationIsFresh(attestation) {
return emptyRPCResult(
out,
nil,
"Rust current official distribution attestation is unavailable or not ready",
), nil
}
// Catalog / resource discovery
@@ -604,6 +614,7 @@ func rustAttestationHealth(report *backendrpc.DistributionAttestation) Distribut
if report == nil {
return health
}
health.Available = report.Available
health.Ready = report.Ready
health.Channel = report.Channel
health.ReleaseID = report.ReleaseID
@@ -614,6 +625,7 @@ func rustAttestationHealth(report *backendrpc.DistributionAttestation) Distribut
health.EntryCount = report.EntryCount
health.VerificationGeneration = report.VerificationGeneration
health.VerifiedAt = report.VerifiedAt
health.MaxAgeSeconds = report.MaxAgeSeconds
health.Status = report.Status
health.StatusCode = report.StatusCode
health.IntegrityStatus = report.IntegrityStatus
@@ -639,6 +651,18 @@ func unavailableRustDistributionHealth() DistributionHealth {
}
}
func attestationIsFresh(attestation *backendrpc.DistributionAttestation) bool {
if attestation == nil || attestation.MaxAgeSeconds == 0 || attestation.VerifiedAt == nil {
return false
}
now := uint64(time.Now().Unix())
age := uint64(0)
if now > *attestation.VerifiedAt {
age = now - *attestation.VerifiedAt
}
return age <= attestation.MaxAgeSeconds
}
func snapshotWithDistributionHealth(snapshot *SnapshotSummary, health DistributionHealth) *SnapshotSummary {
if snapshot == nil {
return nil
@@ -729,18 +753,31 @@ func fetchAllManifestEntriesWithPageSize(
if pageSize <= 0 {
return nil, 0, "", fmt.Errorf("manifest page size must be positive")
}
if attestation == nil {
return nil, 0, "", fmt.Errorf("resource.manifest requires a Rust attestation")
}
if !attestation.Available ||
!attestation.Ready ||
attestation.Channel != "official" ||
attestation.IntegrityStatus != "verified" ||
attestation.VerificationGeneration == 0 ||
!attestationIsFresh(attestation) {
return nil, 0, "", fmt.Errorf("resource.manifest attestation is not ready")
}
offset := 0
var all []manifestEntry
var version int
var root, releaseID, publicationIdentity, mappingIdentity, manifestIdentity string
var version, limit int
var channel, root, releaseID, publicationIdentity, mappingIdentity, manifestIdentity string
var generation uint64
total := -1
for {
page, err := backend.ResourceManifest(ctx, backendrpc.ResourceManifestParams{
ReleaseID: attestation.ReleaseID,
ExpectedPublicationIdentity: attestation.PublicationIdentity,
ExpectedManifestIdentity: attestation.ManifestIdentity,
Offset: offset,
Limit: pageSize,
ReleaseID: attestation.ReleaseID,
ExpectedPublicationIdentity: attestation.PublicationIdentity,
ExpectedManifestIdentity: attestation.ManifestIdentity,
ExpectedVerificationGeneration: attestation.VerificationGeneration,
Offset: offset,
Limit: pageSize,
})
if err != nil {
return nil, 0, "", err
@@ -753,19 +790,25 @@ func fetchAllManifestEntriesWithPageSize(
}
if root == "" {
root = page.ResourceRoot
channel = page.Channel
releaseID = page.ReleaseID
publicationIdentity = page.PublicationIdentity
mappingIdentity = page.MappingIdentity
manifestIdentity = page.ManifestIdentity
generation = page.Generation
version = page.ManifestVersion
total = page.TotalEntries
limit = page.Limit
} else if page.ResourceRoot != root ||
page.Channel != channel ||
page.ReleaseID != releaseID ||
page.PublicationIdentity != publicationIdentity ||
page.MappingIdentity != mappingIdentity ||
page.ManifestIdentity != manifestIdentity ||
page.Generation != generation ||
page.ManifestVersion != version ||
page.TotalEntries != total {
page.TotalEntries != total ||
page.Limit != limit {
return nil, 0, "", fmt.Errorf("resource.manifest page identity or total changed")
}
if page.Offset != offset {
@@ -775,7 +818,7 @@ func fetchAllManifestEntriesWithPageSize(
page.Offset,
)
}
if page.Limit <= 0 || page.Limit > pageSize {
if 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 {
@@ -784,12 +827,14 @@ func fetchAllManifestEntriesWithPageSize(
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 ||
if channel != attestation.Channel ||
releaseID != attestation.ReleaseID ||
root != attestation.ResourceRoot ||
publicationIdentity != attestation.PublicationIdentity ||
mappingIdentity != attestation.MappingIdentity ||
manifestIdentity != attestation.ManifestIdentity ||
total != attestation.EntryCount) {
generation != attestation.VerificationGeneration ||
total != attestation.EntryCount {
return nil, 0, "", fmt.Errorf("resource.manifest page does not match attestation")
}
for _, e := range page.Entries {
@@ -812,5 +857,12 @@ func fetchAllManifestEntriesWithPageSize(
return nil, 0, "", fmt.Errorf("resource.manifest page has a gap before total")
}
}
if len(all) != total {
return nil, 0, "", fmt.Errorf(
"resource.manifest final entry count mismatch: entries=%d total=%d",
len(all),
total,
)
}
return all, version, root, nil
}
+4 -2
View File
@@ -8,8 +8,10 @@ contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本
- `catalog.status` 可用与不可用响应。
- `resource.manifest` 第一页分页响应,包含 release/publication/mapping/manifest
identity 和 generation 绑定字段
- `release.attestation` 当前 official health/publication proof
identity 和 generation 绑定字段;生产请求必须回传 attestation 的
`expected_verification_generation`
- `release.attestation` 当前 official health/publication proof,默认 fixture freshness
window 为 7260 秒(默认 3600 秒验证周期和 60 秒失败重试周期)。
- 对应 release 的 `official-sync-snapshot.json`
- `launcher_metadata``game_main_config_bootstrap` 的 Go mirror 解码。
- Rust Glossary V1 query 响应,覆盖 alias、approved review、source provenance 和完整 history。
+1 -1
View File
@@ -6,7 +6,7 @@
"integrity_status": "verified",
"manifest_identity": "${MANIFEST_IDENTITY}",
"mapping_identity": "${MAPPING_IDENTITY}",
"max_age_seconds": 900,
"max_age_seconds": 7260,
"publication_identity": "${PUBLICATION_IDENTITY}",
"ready": true,
"release_id": "${VERSION_ID}",
+7 -6
View File
@@ -879,11 +879,12 @@ type ResourceManifestEntry struct {
// 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"`
ReleaseID string `json:"release_id,omitempty"`
ExpectedPublicationIdentity string `json:"expected_publication_identity,omitempty"`
ExpectedManifestIdentity string `json:"expected_manifest_identity,omitempty"`
ExpectedVerificationGeneration uint64 `json:"expected_verification_generation"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
type ResourceManifestPage struct {
@@ -895,7 +896,7 @@ type ResourceManifestPage struct {
PublicationIdentity string `json:"publication_identity,omitempty"`
MappingIdentity string `json:"mapping_identity,omitempty"`
ManifestIdentity string `json:"manifest_identity,omitempty"`
Generation uint64 `json:"generation,omitempty"`
Generation uint64 `json:"generation"`
TotalEntries int `json:"total_entries,omitempty"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
+8 -6
View File
@@ -273,7 +273,7 @@ func TestReleaseAttestationMirrorsCurrentOfficialHealth(t *testing.T) {
"ready": true,
"verification_generation": 7,
"verified_at": 1234,
"max_age_seconds": 900,
"max_age_seconds": 7260,
},
},
}
@@ -302,6 +302,7 @@ func TestResourceManifestSendsAttestedGenerationParams(t *testing.T) {
if params.ReleaseID != "official-a" ||
params.ExpectedPublicationIdentity != "odp-v1-publication-a" ||
params.ExpectedManifestIdentity != "manifest-a" ||
params.ExpectedVerificationGeneration != 7 ||
params.Offset != 1 || params.Limit != 100 {
t.Fatalf("params = %#v", params)
}
@@ -329,11 +330,12 @@ func TestResourceManifestSendsAttestedGenerationParams(t *testing.T) {
})
page, err := client.ResourceManifest(context.Background(), ResourceManifestParams{
ReleaseID: "official-a",
ExpectedPublicationIdentity: "odp-v1-publication-a",
ExpectedManifestIdentity: "manifest-a",
Offset: 1,
Limit: 100,
ReleaseID: "official-a",
ExpectedPublicationIdentity: "odp-v1-publication-a",
ExpectedManifestIdentity: "manifest-a",
ExpectedVerificationGeneration: 7,
Offset: 1,
Limit: 100,
})
if err != nil {
t.Fatalf("ResourceManifest error: %v", err)
+11
View File
@@ -144,18 +144,28 @@ require_contains "docs/reference/rpc-backend-api.md" "localized_release_status"
require_contains "docs/reference/rpc-backend-api.md" "official_distribution_ready"
require_contains "docs/reference/rpc-backend-api.md" "release.attestation"
require_contains "docs/reference/rpc-backend-api.md" "expected_manifest_identity"
require_contains "docs/reference/rpc-backend-api.md" "expected_verification_generation"
require_contains "docs/architecture/resource-release-layout.md" "release.attestation"
require_contains "Makefile" "check-docs:"
require_contains "Makefile" "check-go-format:"
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/check-go-format.sh"
require_file "scripts/ci-versions.sh"
require_contains "scripts/ci-check.sh" "RUN required:"
require_contains "scripts/ci-check.sh" "make check-go-format"
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"'
require_contains "docs/guides/development.md" "golangci-lint --version"
require_contains "docs/guides/development.md" "required gate"
if grep -RIEq --exclude=check-doc-status.sh --exclude-dir=.git --exclude-dir=archive --exclude-dir=historical \
-- '可选 lint|optional lint|optional golangci-lint' .; then
fail "current documentation still describes Go lint as optional"
fi
if grep -Fq "ci: fmt" Makefile; then
fail "Makefile ci target must not run the mutating fmt target"
fi
@@ -164,6 +174,7 @@ 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" "make check-go-format"
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"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
: "${GOCACHE:=/tmp/bat-go-cache}"
export GOCACHE
mapfile -t go_dirs < <(go list -f '{{.Dir}}' ./...)
go_files=()
for dir in "${go_dirs[@]}"; do
while IFS= read -r -d '' file; do
go_files+=("${file}")
done < <(find "${dir}" -maxdepth 1 -type f -name '*.go' -print0)
done
if ((${#go_files[@]} == 0)); then
exit 0
fi
formatted="$(gofmt -l "${go_files[@]}")"
if [[ -n "${formatted}" ]]; then
printf 'gofmt required; files need formatting:\n%s\n' "${formatted}" >&2
exit 1
fi
+1 -27
View File
@@ -20,34 +20,8 @@ run_required() {
printf 'PASS required: %s\n' "${name}"
}
check_gofmt() {
local -a go_dirs=()
local -a go_files=()
local dir file formatted dirs_file
dirs_file="$(mktemp)"
if ! go list -f '{{.Dir}}' ./... >"${dirs_file}"; then
rm -f "${dirs_file}"
return 1
fi
mapfile -t go_dirs <"${dirs_file}"
rm -f "${dirs_file}"
for dir in "${go_dirs[@]}"; do
for file in "${dir}"/*.go; do
[[ -f "${file}" ]] && go_files+=("${file}")
done
done
if ((${#go_files[@]} == 0)); then
return
fi
formatted="$(gofmt -l "${go_files[@]}")"
if [[ -n "${formatted}" ]]; then
printf 'gofmt required; files need formatting:\n%s\n' "${formatted}" >&2
return 1
fi
}
run_required "Rust formatting" cargo fmt --all -- --check
run_required "Go formatting" check_gofmt
run_required "Go formatting" make check-go-format
run_required "Rust check" cargo check --workspace --locked
run_required "Rust release build" cargo build --workspace --release --locked
run_required "Rust clippy" cargo clippy --workspace --all-targets --locked -- -D warnings