From 99355effe4f240264ed5d98dac1696216071f1c2 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Tue, 15 Sep 2026 21:13:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(release):=20=E5=AE=8C=E6=88=90=E5=88=86?= =?UTF-8?q?=E5=8F=91=E8=AF=81=E6=98=8E=E4=BB=A3=E9=99=85=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E4=B8=8E=E8=B4=A8=E9=87=8F=E9=97=A8=E7=A6=81=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/bat.yml | 4 +- CURRENT_STATUS.md | 7 +- Makefile | 6 +- TODO.md | 36 +- .../architecture/official-resource-backend.md | 4 +- docs/architecture/resource-release-layout.md | 8 + docs/guides/deployment.md | 2 +- docs/guides/development.md | 8 +- docs/reference/rpc-backend-api.md | 10 +- docs/reports/GO_STATUS.md | 4 +- infrastructure/src/bin/bat/app.rs | 133 ++++-- infrastructure/src/bin/bat/app_tests.rs | 219 +++++++++- infrastructure/src/bin/bat/task_registry.rs | 30 +- infrastructure/src/lib.rs | 7 +- infrastructure/src/official_download.rs | 143 ++++++- infrastructure/src/official_update.rs | 387 ++++++++++++++++-- infrastructure/src/release_ops.rs | 76 +++- internal/api/api_test.go | 11 +- internal/api/contract_fixture_test.go | 1 + internal/api/release_attestation_test.go | 46 ++- internal/api/release_index.go | 6 +- internal/api/rpc_release.go | 78 +++- internal/api/testdata/contract/README.md | 6 +- .../contract/release-attestation.json | 2 +- internal/backendrpc/client.go | 13 +- internal/backendrpc/client_test.go | 14 +- scripts/check-doc-status.sh | 11 + scripts/check-go-format.sh | 26 ++ scripts/ci-check.sh | 28 +- 29 files changed, 1137 insertions(+), 189 deletions(-) create mode 100644 scripts/check-go-format.sh diff --git a/.gitea/workflows/bat.yml b/.gitea/workflows/bat.yml index 6d19911..8086965 100644 --- a/.gitea/workflows/bat.yml +++ b/.gitea/workflows/bat.yml @@ -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 diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index f823cad..380be6d 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -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。 生产要求: diff --git a/Makefile b/Makefile index 35ffe3b..8cc2675 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/TODO.md b/TODO.md index 65d774e..620838f 100644 --- a/TODO.md +++ b/TODO.md @@ -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/` 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` 均已通过。 建议统一覆盖: diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 5b529a0..ba98297 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -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`、 diff --git a/docs/architecture/resource-release-layout.md b/docs/architecture/resource-release-layout.md index 826a578..c6bae92 100644 --- a/docs/architecture/resource-release-layout.md +++ b/docs/architecture/resource-release-layout.md @@ -270,6 +270,14 @@ distribution 查询只读取该文件、当前 manifest 的内容 identity 和 `destination_index`,再校验目标文件的 size/BLAKE3;不会为了定位一个 destination 重做完整 mapping canonicalization 或遍历其他资源。 +`official-distribution-attestation.json` 是 Rust full local verification 的结果。发布时 +文件先写入 `.staging/`,但其中的 `resource_root` 永远记录最终的 +`versions/` 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) diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index 0b3fc8b..ee2c720 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -439,7 +439,7 @@ BAT_API_TRUST_PROXY_HEADERS=true 否则保持默认 `false`,`bat-api` 会按 TCP peer IP 做限流和日志归因。应用层访问日志只记录 path,不记录 query string,避免 query token 进入日志。动态 JSON 响应使用 `Cache-Control: no-store`;CDN 字节路径仍使用长期 immutable 缓存。 -不要在生产 env 里设置 `BAT_API_RESOURCE_ROOT`。`bat-api` 会按 `BAT_API_REFRESH_INTERVAL` 周期通过 RPC 读取当前 official `release.attestation`,再以同一 release/publication/manifest identity 请求 `resource.manifest`,从而跟随 Rust `bat` 切换 `current -> versions/`;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/`;attestation 过期、generation 变化或分页不一致时 fail closed。 ### 健康检查 diff --git a/docs/guides/development.md b/docs/guides/development.md index b1d5591..85bd93d 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -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 diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index 8e42f69..7b7e96a 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -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`、 diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index f41e4ce..e262d58 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -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` | diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index b85d30b..e23ebbf 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -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 { } 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, #[serde(default)] + expected_verification_generation: Option, + #[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 { 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 { zip_error: item.zip_error.clone(), }) .collect::>(); - 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(); diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index d81ef54..bae6052 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -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() diff --git a/infrastructure/src/bin/bat/task_registry.rs b/infrastructure/src/bin/bat/task_registry.rs index dc2c304..6953f2f 100644 --- a/infrastructure/src/bin/bat/task_registry.rs +++ b/infrastructure/src/bin/bat/task_registry.rs @@ -537,8 +537,8 @@ pub(super) fn run_task_worker( registry: TaskRegistry, sync_lock: Arc>, 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) }; diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 221560e..0e92176 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -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::{ diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index a65e5ca..361c3c8 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -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, + max_age_seconds: u64, + mut diagnostics: Vec, ) -> Result { 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 = [ diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index 2304872..3901249 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -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 { 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(); diff --git a/infrastructure/src/release_ops.rs b/infrastructure/src/release_ops.rs index 251466a..5a18f68 100644 --- a/infrastructure/src/release_ops.rs +++ b/infrastructure/src/release_ops.rs @@ -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] diff --git a/internal/api/api_test.go b/internal/api/api_test.go index c9edcb2..03166fc 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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 } diff --git a/internal/api/contract_fixture_test.go b/internal/api/contract_fixture_test.go index 427e0b9..924d3f8 100644 --- a/internal/api/contract_fixture_test.go +++ b/internal/api/contract_fixture_test.go @@ -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) } diff --git a/internal/api/release_attestation_test.go b/internal/api/release_attestation_test.go index 32dece4..470eff6 100644 --- a/internal/api/release_attestation_test.go +++ b/internal/api/release_attestation_test.go @@ -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) + } +} diff --git a/internal/api/release_index.go b/internal/api/release_index.go index fb40270..fbdb077 100644 --- a/internal/api/release_index.go +++ b/internal/api/release_index.go @@ -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", diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index e53c347..2646556 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -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 } diff --git a/internal/api/testdata/contract/README.md b/internal/api/testdata/contract/README.md index e43391d..25edc81 100644 --- a/internal/api/testdata/contract/README.md +++ b/internal/api/testdata/contract/README.md @@ -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。 diff --git a/internal/api/testdata/contract/release-attestation.json b/internal/api/testdata/contract/release-attestation.json index 617aa04..0c3f8cc 100644 --- a/internal/api/testdata/contract/release-attestation.json +++ b/internal/api/testdata/contract/release-attestation.json @@ -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}", diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index 5254959..f403265 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -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"` diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index 5a1eb8c..bc3f3f8 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -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) diff --git a/scripts/check-doc-status.sh b/scripts/check-doc-status.sh index d82b465..8d3fd08 100644 --- a/scripts/check-doc-status.sh +++ b/scripts/check-doc-status.sh @@ -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" diff --git a/scripts/check-go-format.sh b/scripts/check-go-format.sh new file mode 100644 index 0000000..46fed4a --- /dev/null +++ b/scripts/check-go-format.sh @@ -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 diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index 4d40464..54f86d1 100644 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -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