mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
fix(release): 完成分发证明代际绑定与质量门禁收口
This commit is contained in:
@@ -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
@@ -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 fixture,Go mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.*`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
|
||||
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取 Rust 当前 official `release.attestation`,再按 release/publication/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 fixture,Go mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.*`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
|
||||
|
||||
生产要求:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>` root:publication
|
||||
在 staging 写入但 JSON root 绑定最终 versions path,rename 不增加 verification
|
||||
generation;full 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)
|
||||
|
||||
@@ -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。
|
||||
|
||||
### 健康检查
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`、
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
| ID | 约定 |
|
||||
|---|---|
|
||||
| F | 版本/清单经 **`bat.sock` JSON-RPC**(`--socket`);不读 daemon 内部状态文件 |
|
||||
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再轻量 **`release.attestation`**,再 catalog/manifest;manifest 请求绑定 attested release/publication/manifest identity |
|
||||
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再轻量 **`release.attestation`**,再 catalog/manifest;manifest 请求绑定 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 helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`release.attestation/status/list/distribution/cleanup`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`resource.manifest` typed params 固定 release/publication/manifest identity;`localized.publish` 的 typed params 支持 `translation_file`、`from_worker` 或 `patch_manifest` 三选一;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
|
||||
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`release.attestation/status/list/distribution/cleanup`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`resource.manifest` typed params 固定 release/publication/manifest identity 和 verification generation;`localized.publish` 的 typed params 支持 `translation_file`、`from_worker` 或 `patch_manifest` 三选一;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
|
||||
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + `/v1/releases` + `/v1/distribution` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + release/localized/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` |
|
||||
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
||||
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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(¤t_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(¤t_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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user