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