mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 10:04:55 +08:00
fix(release):完善当前分发证明与质量门禁
This commit is contained in:
@@ -249,18 +249,18 @@ jobs:
|
|||||||
source /var/lib/act_runner/env.sh
|
source /var/lib/act_runner/env.sh
|
||||||
go build -o /tmp/bat-api ./cmd/bat-api
|
go build -o /tmp/bat-api ./cmd/bat-api
|
||||||
|
|
||||||
- name: Optional Go lint
|
- name: Required Go lint
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
source /var/lib/act_runner/env.sh
|
source /var/lib/act_runner/env.sh
|
||||||
if command -v golangci-lint >/dev/null 2>&1; then
|
source scripts/ci-versions.sh
|
||||||
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}"
|
command -v golangci-lint >/dev/null 2>&1
|
||||||
golangci-lint run ./...
|
actual="$(golangci_lint_actual_version)"
|
||||||
else
|
test "${actual}" = "${GOLANGCI_LINT_VERSION}"
|
||||||
printf 'SKIP optional: Go lint (golangci-lint); reason=command not installed\n'
|
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}"
|
||||||
fi
|
golangci-lint run ./...
|
||||||
|
|
||||||
- name: Run documentation status gate
|
- name: Run documentation status gate
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
+7
-5
@@ -49,8 +49,10 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
|
|||||||
`localized.status` 现将 generic manifest schema/contract 与已发布 artifact integrity
|
`localized.status` 现将 generic manifest schema/contract 与已发布 artifact integrity
|
||||||
分开报告;current、state 和 identity 存在但文件被截断或手工修改时返回
|
分开报告;current、state 和 identity 存在但文件被截断或手工修改时返回
|
||||||
`localized.degraded`,只读检查不会自动回滚、删除或修复。双 release 的
|
`localized.degraded`,只读检查不会自动回滚、删除或修复。双 release 的
|
||||||
`release.status/list/distribution/cleanup` 已由 Rust 从既有状态、manifest、文件系统和
|
`release.attestation` 已由 Rust 从 current、publication anchor、manifest identity 和
|
||||||
CAS/reference 元数据统一生成,Go 仅 typed 转发。CAS repository 的对象文件、引用计数
|
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
|
和 GC 通过跨进程操作锁协调,release-local CAS 引用以 `(ownership_id, ordinal)` ownership
|
||||||
记录幂等释放;新清单持久化 `ownership_id`,旧清单按 output-root scope、稳定 source
|
记录幂等释放;新清单持久化 `ownership_id`,旧清单按 output-root scope、稳定 source
|
||||||
identity 和 generation-aware legacy cleanup path 迁移;localized publish/rollback 通过
|
identity 和 generation-aware legacy cleanup path 迁移;localized publish/rollback 通过
|
||||||
@@ -263,8 +265,8 @@ marker 存在时才保持兼容 key,无法证明归属时拒绝 cleanup;完
|
|||||||
| Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 |
|
| Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 |
|
||||||
|
|
||||||
默认 Go/docs 只读门禁:`make ci-check`(Rust fmt/check/build/clippy/test、Go API
|
默认 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`
|
format/test/vet/build、固定版本 `golangci-lint 2.12.2`、docs/OpenAPI/RPC contract;
|
||||||
才会修改源码;`golangci-lint` 缺失时作为 optional gate 明确输出 skipped。
|
无 FFI)。`make format` / `make fmt` 才会修改源码;required 工具缺失或版本不匹配直接失败。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -308,7 +310,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
|||||||
--watch
|
--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)"
|
@echo "$(BLUE)Running Clippy...$(NC)"
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
lint-go: ## Go Linter 检查
|
lint-go: ## Go Linter 检查(required)
|
||||||
@echo "$(BLUE)Running golangci-lint...$(NC)"
|
@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; }
|
@. scripts/ci-versions.sh; \
|
||||||
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
|
command -v golangci-lint >/dev/null 2>&1 || { \
|
||||||
XDG_CACHE_HOME="$${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" golangci-lint run ./...; \
|
echo "$(YELLOW)required gate failed: golangci-lint $${GOLANGCI_LINT_VERSION} is not installed$(NC)"; \
|
||||||
else \
|
exit 1; \
|
||||||
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
|
}; \
|
||||||
fi
|
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/CD
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
ci-check: ## 运行只读 required CI 门禁(可选 lint 会明确标记)
|
ci-check: ## 运行只读 required CI 门禁(含固定版本 Go lint)
|
||||||
@bash scripts/ci-check.sh
|
@bash scripts/ci-check.sh
|
||||||
|
|
||||||
ci: ci-check ## 运行只读 CI 检查(兼容旧命令名)
|
ci: ci-check ## 运行只读 CI 检查(兼容旧命令名)
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ make ci-check
|
|||||||
```
|
```
|
||||||
|
|
||||||
`make ci-check` 是只读 required 门禁;`make format` / `make fmt` 才会格式化源码。
|
`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 是否存在;
|
* manifest entry 是否存在;
|
||||||
* 文件 size 是否匹配。
|
* 文件 size 是否匹配。
|
||||||
|
|
||||||
虽然 Rust 已有完整的发布状态、BLAKE3 校验与 release integrity 语义,但 Go 当前并未完整继承这一健康状态。
|
虽然 Rust 已有完整的发布状态、BLAKE3 校验与 release integrity 语义,但 Go 当前需要继承
|
||||||
|
一个轻量、带代际绑定和 freshness 的 current official health proof,而不是触发重型历史扫描。
|
||||||
|
|
||||||
因此可能出现:
|
因此可能出现:
|
||||||
|
|
||||||
@@ -76,9 +77,9 @@
|
|||||||
建立统一的:
|
建立统一的:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Rust release health
|
Rust current release attestation
|
||||||
↓
|
↓
|
||||||
Go ReleaseIndex
|
Go ReleaseIndex(保留 release/publication/manifest identity)
|
||||||
↓
|
↓
|
||||||
readiness / bootstrap
|
readiness / bootstrap
|
||||||
↓
|
↓
|
||||||
@@ -93,6 +94,13 @@ CDN distribution gate
|
|||||||
* bootstrap 不应报告 distributable;
|
* bootstrap 不应报告 distributable;
|
||||||
* CDN 不应继续从该 release 分发任意文件。
|
* 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
|
* GET
|
||||||
@@ -143,10 +151,12 @@ B 同大小内容损坏,Rust health=false
|
|||||||
|
|
||||||
## 完成记录
|
## 完成记录
|
||||||
|
|
||||||
Rust `release.status.official_distribution_ready` 已接入 Go refresh snapshot;
|
当前已完成 Rust current attestation、manifest 代际绑定、Go typed mirror、分页一致性和
|
||||||
`readyz`、bootstrap、release summary 和普通 current CDN 共用 whole-release
|
A→B 切换 fail-closed 测试;`readyz`、bootstrap、release summary 和普通 current CDN
|
||||||
distribution gate。刷新失败会清空旧 index,恢复后重新建立快照;显式
|
共用 `attestation ready + exact manifest generation + complete local snapshot` gate。
|
||||||
official/localized/historical 分发仍经 Rust `release.distribution`。
|
刷新失败会清空旧 index,恢复后重新建立快照;显式 official/localized/historical 分发
|
||||||
|
仍经 Rust `release.distribution`。`make ci-check`、`make test-go-api` 和
|
||||||
|
`make check-docs` 均已通过。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -544,14 +554,17 @@ CI 检查必须:
|
|||||||
* 不修改源码;
|
* 不修改源码;
|
||||||
* 明确报告每项 gate;
|
* 明确报告每项 gate;
|
||||||
* required tool 缺失时不应伪装成全部通过;
|
* required tool 缺失时不应伪装成全部通过;
|
||||||
* optional gate 明确标记 optional;
|
* required tool 缺失或版本不匹配必须失败;
|
||||||
* 与实际 Gitea CI 尽量保持一致。
|
* 与实际 Gitea CI 尽量保持一致。
|
||||||
|
|
||||||
## 完成记录
|
## 完成记录
|
||||||
|
|
||||||
`make format` / `make fmt` 保留为显式写入命令,`make ci-check` 和兼容的
|
`make format` / `make fmt` 保留为显式写入命令,`make ci-check` 和兼容的
|
||||||
`make ci` 只执行 read-only required gates;Go lint 在工具缺失时明确输出
|
`make ci` 只执行 read-only required gates;`golangci-lint 2.12.2` 由
|
||||||
optional skipped,OpenAPI、RPC contract 和文档一致性由 `make check-docs` 纳入。
|
`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
|
summary: Release readiness
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"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":
|
"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:
|
/v1/bootstrap:
|
||||||
get:
|
get:
|
||||||
summary: Startup resource bootstrap
|
summary: Startup resource bootstrap
|
||||||
|
|||||||
@@ -9,7 +9,3 @@ func InspectManifest(rawJSON string) (string, error) {
|
|||||||
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
|
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
|
||||||
return ffi.BuildSyncPlan(currentJSON, previousJSON)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(os.Stdout, result)
|
_, err = fmt.Fprintln(os.Stdout, result)
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,10 +371,10 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
|||||||
- CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download
|
- CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download
|
||||||
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
|
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
|
||||||
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再读取
|
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再读取
|
||||||
Rust `release.status` 的 whole-release `official_distribution_ready`,最后
|
Rust 轻量 `release.attestation`,最后按 attested release/publication/manifest
|
||||||
读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。
|
identity 读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket
|
||||||
Go 不重新实现 release verifier;普通 current CDN 只有在该 Rust health fact
|
文件)。Go 不重新实现 release verifier;普通 current CDN 只有在 attestation
|
||||||
和本地只读 manifest 快照都允许时才分发。
|
fresh/ready、分页快照完整且本地只读路径检查都允许时才分发。
|
||||||
- `/v1/releases`、`/v1/distribution` 和受保护的 `/admin/releases/status`、
|
- `/v1/releases`、`/v1/distribution` 和受保护的 `/admin/releases/status`、
|
||||||
`/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed
|
`/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed
|
||||||
结果;localized 或历史分发不会绕过 Rust 完整性判断。
|
结果;localized 或历史分发不会绕过 Rust 完整性判断。
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
|
|
||||||
### 2.1 双 release 读写边界
|
### 2.1 双 release 读写边界
|
||||||
|
|
||||||
Rust `bat` 的 `release.status` 是 official/localized 的统一只读视图,基于既有
|
Rust `bat` 的 `release.status` 是 official/localized 的统一重型只读视图,基于既有
|
||||||
version state、current symlink、release manifest、文件系统和必要的 CAS/reference
|
version state、current symlink、release manifest、文件系统和必要的 CAS/reference
|
||||||
元数据计算,不建立第二个 release 数据库。`release.list` 返回两个 namespace 的当前
|
元数据计算,不建立第二个 release 数据库。`release.list` 返回两个 namespace 的当前
|
||||||
与历史 release,包含稳定 ID、created/published、source official relation、生命周期、
|
与历史 release,包含稳定 ID、created/published、source official relation、生命周期、
|
||||||
@@ -382,10 +382,11 @@ Addressables 改写后客户端拼接:
|
|||||||
|
|
||||||
1. `daemon.status`
|
1. `daemon.status`
|
||||||
2. `daemon.doctor`
|
2. `daemon.doctor`
|
||||||
3. `release.status`,消费 Rust 返回的当前 official `official_distribution_ready`
|
3. `release.attestation`,消费 Rust 当前 official 的 `ready`、release/publication/
|
||||||
及其 `status` / `status_code` / integrity 事实
|
manifest identity、verification generation、freshness 和 integrity 事实
|
||||||
4. `catalog.status`(`version.resource_root`、`addressables_root`、app/bundle)
|
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 读快照,
|
6. 在 `resource_root` 上 Lstat 校验 Present / size;该检查只验证 Go 读快照,
|
||||||
不替代 Rust release verifier
|
不替代 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。
|
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` 不应格式化源码。
|
3. `make format` / `make fmt` 会修改工作树;`make ci-check`、`make check`、`make test` 和 `make lint` 不应格式化源码。
|
||||||
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`。
|
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 缓存。
|
否则保持默认 `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 ci-check` 是只读门禁入口;`make format` / `make fmt` 才会修改源码。缺少
|
`make ci-check` 是只读门禁入口;`make format` / `make fmt` 才会修改源码。
|
||||||
`golangci-lint` 时,Go lint 作为 optional gate 明确输出 skipped,不会伪报全部门禁通过。
|
required 的 `golangci-lint 2.12.2` 由 `scripts/ci-versions.sh` 固定,缺失或版本不匹配
|
||||||
|
都会失败,不会伪报全部门禁通过。
|
||||||
|
|
||||||
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
|
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
|
||||||
|
|
||||||
- **同步/运维命令行** = Rust `bat`(近乎全自动)
|
- **同步/运维命令行** = Rust `bat`(近乎全自动)
|
||||||
- **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api`(`make build-go-api`)
|
- **资源 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` 重名
|
- 试验 CLI 产物为 `bin/bat-go`(`make build-go-cli`),**禁止**与 Rust `bat` 重名
|
||||||
- 修改 FFI 时再跑 `make test-go-ffi`
|
- 修改 FFI 时再跑 `make test-go-ffi`
|
||||||
|
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主
|
|||||||
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
|
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
|
||||||
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
|
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
|
||||||
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
|
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
|
||||||
| `resource.manifest` | 已实现 | `{ "offset": 0, "limit": 100 }` | 当前 download manifest 分页。 |
|
| `resource.manifest` | 已实现 | `{ "release_id": "...", "expected_publication_identity": "...", "expected_manifest_identity": "...", "offset": 0, "limit": 100 }` | 绑定一个 Rust attested official generation 的 download manifest 分页。 |
|
||||||
| `resource.list` | 已实现 | `{ "offset": 0, "limit": 100 }` | `resource.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.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`。
|
`resource.repair` 会开启本地 manifest audit + repair,不继承 `force`。
|
||||||
@@ -119,6 +119,14 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
|||||||
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
|
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
|
||||||
`1..=1000`,非法参数返回 `BAT-ERR-700002`。
|
`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`、
|
`resource.index` 的 `entries[]` 是 `Resource` JSON,除 `id`、`local_path`、
|
||||||
`entry` 外会包含 `metadata`:`official_release_id`、`platform`、
|
`entry` 外会包含 `metadata`:`official_release_id`、`platform`、
|
||||||
`bundle_path`、`archive_entries`、`parse_statuses`、`unity_versions`、
|
`bundle_path`、`archive_entries`、`parse_statuses`、`unity_versions`、
|
||||||
@@ -176,7 +184,8 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
|||||||
|
|
||||||
| 方法 | 状态 | params | data |
|
| 方法 | 状态 | 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.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.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`。 |
|
| `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 | 用途 |
|
| Go 接口 | 允许调用的 RPC | 用途 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`resource.manifest` | 启动发现、周期刷新和资源分发基础数据 |
|
| `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`release.attestation`、绑定后的 `resource.manifest` | 启动发现、周期刷新和资源分发基础数据 |
|
||||||
| `ReleaseStatusBackend` | `release.status` | 启动发现和周期刷新使用 Rust-owned whole-release distribution health;Go 不重新实现 verifier |
|
| `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` | 鉴权后的管理控制白名单 |
|
| `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 调度计划控制 |
|
| `ScheduleBackend` | `schedule.list`、`schedule.add`、`schedule.update`、`schedule.remove`、`schedule.run` | 鉴权后的 dashboard 调度计划控制 |
|
||||||
| `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 |
|
| `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 |
|
||||||
|
|||||||
@@ -76,9 +76,11 @@ launcher 资源引导兼容、只读 CDN path、readiness、OpenAPI、鉴权管
|
|||||||
dashboard;翻译任务和 Rust-owned TM 的 summary/query/confirm 也通过 typed RPC
|
dashboard;翻译任务和 Rust-owned TM 的 summary/query/confirm 也通过 typed RPC
|
||||||
转发。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。
|
转发。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。
|
||||||
普通 current release 的 readiness、bootstrap、release summary 和 CDN 共用
|
普通 current release 的 readiness、bootstrap、release summary 和 CDN 共用
|
||||||
Rust `release.status.official_distribution_ready` whole-release health fact;Go
|
Rust `release.attestation` 是 current official 的轻量 health/publication proof,带
|
||||||
只建立 manifest 的读快照,不复制 Rust verifier。`make ci-check` 是只读统一门禁,
|
release/publication/manifest identity、verification generation、freshness 和诊断;Go
|
||||||
缺少 `golangci-lint` 时明确报告 optional skipped。
|
只建立绑定同一代际的 manifest 读快照,不复制 Rust verifier。`release.status` 仍保留
|
||||||
|
为重型管理诊断。`make ci-check` 是只读统一门禁,required `golangci-lint 2.12.2`
|
||||||
|
缺失或版本不匹配直接失败。
|
||||||
|
|
||||||
仍需完成:
|
仍需完成:
|
||||||
|
|
||||||
|
|||||||
@@ -61,11 +61,11 @@
|
|||||||
| ID | 约定 |
|
| ID | 约定 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| F | 版本/清单经 **`bat.sock` JSON-RPC**(`--socket`);不读 daemon 内部状态文件 |
|
| 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 或应急只读诊断 |
|
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
|
||||||
| I | 生产中 Rust `bat` 与 `bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 |
|
| I | 生产中 Rust `bat` 与 `bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 |
|
||||||
| J | Go 索引以 **manifest + 磁盘 Present/size** 建立可读快照,但不将其当作 release integrity |
|
| 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 完全没有」等过时句 |
|
| O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 |
|
||||||
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
|
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
|
||||||
| Q | 空目录标明 reserved empty |
|
| 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` | 已用 | 服务层模块名 |
|
| 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` |
|
| 资源 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 |
|
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
||||||
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
||||||
|
|||||||
@@ -7,17 +7,19 @@ use bat_core::{ApiError, ErrorCode};
|
|||||||
use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY;
|
use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY;
|
||||||
use bat_infrastructure::{
|
use bat_infrastructure::{
|
||||||
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
|
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,
|
apply_unityfs_text_asset_patch_file, build_official_distribution_attestation,
|
||||||
changed_endpoint_urls, cleanup_releases, completed_worker_translation_workbench,
|
build_release_list, build_release_status, changed_endpoint_urls, cleanup_releases,
|
||||||
diff_extended_snapshot, export_translation_workbench, gc_orphan_staging_with_cas_root,
|
completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench,
|
||||||
get_translation_entry, inspect_localized_release_artifact, lexical_absolute,
|
gc_orphan_staging_with_cas_root, get_translation_entry, inspect_localized_release_artifact,
|
||||||
localized_patch_operations_with_glossary_path, open_append_file, read_download_manifest_at,
|
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_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_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench,
|
||||||
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy,
|
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy,
|
||||||
select_release_distribution, set_translation, set_translation_checked_with_glossary_path,
|
select_release_distribution, set_translation, set_translation_checked_with_glossary_path,
|
||||||
unset_translation, validate_output_root, validate_runtime_state_dir,
|
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,
|
write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig,
|
||||||
LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
|
LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
|
||||||
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
|
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
|
||||||
@@ -1155,6 +1157,25 @@ struct LocalizedRollbackRpcParams {
|
|||||||
localized_release_id: Option<String>,
|
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.*` 保留为向后兼容别名。
|
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。
|
||||||
const RPC_METHOD_STATUS: &str = "daemon.status";
|
const RPC_METHOD_STATUS: &str = "daemon.status";
|
||||||
const RPC_METHOD_STOP: &str = "daemon.stop";
|
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_PUBLISH: &str = "localized.publish";
|
||||||
const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback";
|
const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback";
|
||||||
const RPC_METHOD_RELEASE_STATUS: &str = "release.status";
|
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_LIST: &str = "release.list";
|
||||||
const RPC_METHOD_RELEASE_DISTRIBUTION: &str = "release.distribution";
|
const RPC_METHOD_RELEASE_DISTRIBUTION: &str = "release.distribution";
|
||||||
const RPC_METHOD_RELEASE_CLEANUP: &str = "release.cleanup";
|
const RPC_METHOD_RELEASE_CLEANUP: &str = "release.cleanup";
|
||||||
@@ -1982,6 +2004,18 @@ fn dispatch_rpc_method(
|
|||||||
"resource.state",
|
"resource.state",
|
||||||
build_resource_state_report(state_dir),
|
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 => {
|
RPC_METHOD_SCHEDULE_LIST => {
|
||||||
let params = request
|
let params = request
|
||||||
.params
|
.params
|
||||||
@@ -2084,23 +2118,27 @@ fn dispatch_rpc_method(
|
|||||||
enqueue_task_envelope(tasks, TaskKind::Repair, false, request_id)
|
enqueue_task_envelope(tasks, TaskKind::Repair, false, request_id)
|
||||||
}
|
}
|
||||||
RPC_METHOD_RESOURCE_MANIFEST => {
|
RPC_METHOD_RESOURCE_MANIFEST => {
|
||||||
let (offset, limit) = match rpc_page_params(request.params.as_ref()) {
|
let params = match rpc_optional_struct_params::<ResourceManifestRpcParams>(
|
||||||
Ok(page) => page,
|
request.params.as_ref(),
|
||||||
Err(error) => {
|
RPC_METHOD_RESOURCE_MANIFEST,
|
||||||
return rpc_envelope_error(
|
) {
|
||||||
request_id,
|
Ok(params) => params,
|
||||||
ApiError::new(
|
Err(error) => return rpc_envelope_error(request_id, error),
|
||||||
ErrorCode::RPC_INVALID_PARAMS,
|
};
|
||||||
"resource.manifest",
|
if params.limit == 0 || params.limit > 1000 {
|
||||||
error.to_string(),
|
return rpc_envelope_error(
|
||||||
),
|
request_id,
|
||||||
)
|
ApiError::new(
|
||||||
}
|
ErrorCode::RPC_INVALID_PARAMS,
|
||||||
|
RPC_METHOD_RESOURCE_MANIFEST,
|
||||||
|
"limit 必须在 1..=1000 范围内",
|
||||||
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
rpc_envelope_from_result(
|
rpc_envelope_from_result(
|
||||||
request_id,
|
request_id,
|
||||||
"resource.manifest",
|
RPC_METHOD_RESOURCE_MANIFEST,
|
||||||
build_resource_manifest_report(state_dir, offset, limit),
|
build_resource_manifest_report(state_dir, params),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
RPC_METHOD_RESOURCE_INDEX => {
|
RPC_METHOD_RESOURCE_INDEX => {
|
||||||
@@ -2969,39 +3007,102 @@ fn build_catalog_diff_report(state_dir: &Path) -> anyhow::Result<serde_json::Val
|
|||||||
/// `resource.manifest`:当前版本下载 manifest 的分页查询。
|
/// `resource.manifest`:当前版本下载 manifest 的分页查询。
|
||||||
fn build_resource_manifest_report(
|
fn build_resource_manifest_report(
|
||||||
state_dir: &Path,
|
state_dir: &Path,
|
||||||
offset: usize,
|
params: ResourceManifestRpcParams,
|
||||||
limit: usize,
|
|
||||||
) -> anyhow::Result<serde_json::Value> {
|
) -> 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
|
let current = version_state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|state| state.current_completed_version.as_ref());
|
.and_then(|state| state.current_completed_version.as_ref());
|
||||||
let Some(record) = current else {
|
let Some(record) = current else {
|
||||||
return Ok(serde_json::json!({ "available": false }));
|
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 manifest = read_download_manifest_at(&record.resource_root).map_err(anyhow::Error::msg)?;
|
||||||
let Some(manifest) = manifest else {
|
let Some(manifest) = manifest else {
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"available": false,
|
"available": false,
|
||||||
|
"release_id": record.id,
|
||||||
"resource_root": record.resource_root,
|
"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();
|
let total_entries = manifest.entries.len();
|
||||||
// BTreeMap 按 URL 有序迭代,分页结果稳定。
|
// BTreeMap 按 URL 有序迭代,分页结果稳定。
|
||||||
let entries: Vec<_> = manifest
|
let entries: Vec<_> = manifest
|
||||||
.entries
|
.entries
|
||||||
.values()
|
.values()
|
||||||
.skip(offset)
|
.skip(params.offset)
|
||||||
.take(limit)
|
.take(params.limit)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"available": true,
|
"available": true,
|
||||||
|
"channel": "official",
|
||||||
|
"release_id": record.id,
|
||||||
"resource_root": record.resource_root,
|
"resource_root": record.resource_root,
|
||||||
"manifest_version": manifest.version,
|
"manifest_version": manifest.version,
|
||||||
|
"publication_identity": publication_identity,
|
||||||
|
"mapping_identity": mapping_identity,
|
||||||
|
"manifest_identity": manifest_identity,
|
||||||
|
"generation": verification_generation,
|
||||||
"total_entries": total_entries,
|
"total_entries": total_entries,
|
||||||
"offset": offset,
|
"offset": params.offset,
|
||||||
"limit": limit,
|
"limit": params.limit,
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -5457,6 +5558,7 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
|
|||||||
zip_error: item.zip_error.clone(),
|
zip_error: item.zip_error.clone(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
verify_and_record_official_distribution_attestation(&config)?;
|
||||||
let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate
|
let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate
|
||||||
&& update_report.local_manifest_repair_needed_count == 0
|
&& update_report.local_manifest_repair_needed_count == 0
|
||||||
&& verification.is_clean();
|
&& verification.is_clean();
|
||||||
|
|||||||
@@ -4453,6 +4453,9 @@ fn dispatch_resource_manifest_paginates() {
|
|||||||
assert_eq!(value["data"]["available"], true);
|
assert_eq!(value["data"]["available"], true);
|
||||||
assert_eq!(value["data"]["total_entries"], 3);
|
assert_eq!(value["data"]["total_entries"], 3);
|
||||||
assert_eq!(value["data"]["offset"], 1);
|
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();
|
let entries = value["data"]["entries"].as_array().unwrap();
|
||||||
assert_eq!(entries.len(), 2);
|
assert_eq!(entries.len(), 2);
|
||||||
assert_eq!(entries[0]["destination"], "b");
|
assert_eq!(entries[0]["destination"], "b");
|
||||||
@@ -4486,6 +4489,24 @@ fn dispatch_resource_manifest_paginates() {
|
|||||||
let entries = value["data"]["entries"].as_array().unwrap();
|
let entries = value["data"]["entries"].as_array().unwrap();
|
||||||
assert_eq!(entries.len(), 1);
|
assert_eq!(entries.len(), 1);
|
||||||
assert_eq!(entries[0]["destination"], "c");
|
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) {
|
fn write_resource_index_fixture(repository_path: &Path) {
|
||||||
|
|||||||
@@ -603,6 +603,14 @@ pub(super) fn run_task_worker(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
run_result
|
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))
|
.map(|report| serde_json::to_value(&report).map_err(anyhow::Error::from))
|
||||||
.and_then(|result| result)
|
.and_then(|result| result)
|
||||||
};
|
};
|
||||||
|
|||||||
+18
-15
@@ -82,13 +82,15 @@ pub use official_changes::{
|
|||||||
pub use official_download::{
|
pub use official_download::{
|
||||||
official_distribution_mapping_identity, read_cas_reuse_reference_manifest_at,
|
official_distribution_mapping_identity, read_cas_reuse_reference_manifest_at,
|
||||||
read_download_manifest_at, release_cas_reuse_references, DownloadError,
|
read_download_manifest_at, release_cas_reuse_references, DownloadError,
|
||||||
OfficialCasReuseReferenceManifest, OfficialDownloadManifest, OfficialDownloadManifestEntry,
|
OfficialCasReuseReferenceManifest, OfficialDistributionAttestation, OfficialDownloadManifest,
|
||||||
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
|
OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem,
|
||||||
OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport,
|
OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus,
|
||||||
OfficialResourceHashAlgorithm, OfficialResourceHashVerification, OfficialResourcePullItem,
|
OfficialLocalVerificationReport, OfficialResourceHashAlgorithm,
|
||||||
OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport,
|
OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress,
|
||||||
OfficialResourcePullService, OfficialResourcePullStatus, OfficialResourceReuseWarning,
|
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
|
||||||
OfficialResourceVerification, OFFICIAL_CAS_REUSE_REFERENCES_FILE,
|
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,
|
OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||||
};
|
};
|
||||||
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
||||||
@@ -130,13 +132,13 @@ pub use official_textunit_queue::{
|
|||||||
pub use official_update::{
|
pub use official_update::{
|
||||||
cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging,
|
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,
|
gc_orphan_staging_with_cas_root, read_bootstrap_cache, read_snapshot, read_version_state,
|
||||||
write_bootstrap_cache, write_snapshot, write_version_state, ExtendedSnapshotDelta,
|
verify_and_record_official_distribution_attestation, write_bootstrap_cache, write_snapshot,
|
||||||
GameMainConfigSnapshot, LauncherMetadataSnapshot, LocalizedReleaseStatus,
|
write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
|
||||||
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot,
|
LocalizedReleaseStatus, OfficialBootstrapCache, OfficialEndpointMarkerRole,
|
||||||
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
|
OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource,
|
||||||
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
|
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||||
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState,
|
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
|
||||||
ResolvedBootstrap,
|
OfficialVersionRecord, OfficialVersionState, ResolvedBootstrap,
|
||||||
};
|
};
|
||||||
pub use patch_ops::{
|
pub use patch_ops::{
|
||||||
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
|
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_flow::ReleaseFlowStatusCode;
|
||||||
pub use release_ops::{
|
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,
|
ReleaseCleanupParams, ReleaseCleanupReport, ReleaseDistributionEntry, ReleaseDistributionPage,
|
||||||
ReleaseDistributionParams, ReleaseListParams, ReleaseStatusReport, ReleaseSummary,
|
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";
|
const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
|
||||||
/// Independent publication fact for the official distribution manifest.
|
/// Independent publication fact for the official distribution manifest.
|
||||||
pub const OFFICIAL_DISTRIBUTION_PUBLICATION_FILE: &str = "official-distribution-publication.json";
|
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 引用。
|
/// 记录一个已发布官方 release 获取的 CAS 引用。
|
||||||
pub const OFFICIAL_CAS_REUSE_REFERENCES_FILE: &str = "official-cas-reuse-references.json";
|
pub const OFFICIAL_CAS_REUSE_REFERENCES_FILE: &str = "official-cas-reuse-references.json";
|
||||||
const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1;
|
const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1;
|
||||||
@@ -566,6 +572,154 @@ pub(crate) struct OfficialDistributionPublicationAnchor {
|
|||||||
|
|
||||||
const OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION: u32 = 1;
|
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
|
/// Writes the independent publication anchor after the complete official
|
||||||
/// release verification has succeeded.
|
/// release verification has succeeded.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ use crate::official_changes::{
|
|||||||
OfficialResourceChangeSummary,
|
OfficialResourceChangeSummary,
|
||||||
};
|
};
|
||||||
use crate::official_download::{
|
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,
|
OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||||
};
|
};
|
||||||
use crate::official_game_main_config::{
|
use crate::official_game_main_config::{
|
||||||
@@ -1785,6 +1786,28 @@ impl OfficialUpdateService {
|
|||||||
&active_resource_root,
|
&active_resource_root,
|
||||||
&snapshot_path,
|
&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 =
|
let active_launcher_bootstrap_path =
|
||||||
active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE);
|
active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE);
|
||||||
if bootstrap.is_some()
|
if bootstrap.is_some()
|
||||||
@@ -2015,6 +2038,13 @@ impl OfficialUpdateService {
|
|||||||
&publish_plan.id,
|
&publish_plan.id,
|
||||||
)
|
)
|
||||||
.map_err(anyhow::Error::msg)?;
|
.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(
|
progress(OfficialUpdateProgress::new(
|
||||||
"snapshot",
|
"snapshot",
|
||||||
format!("写入快照 {}", staging_snapshot_path.display()),
|
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(
|
fn run_post_sync_resource_handoff(
|
||||||
previous_resource_root: Option<&Path>,
|
previous_resource_root: Option<&Path>,
|
||||||
current_resource_root: &Path,
|
current_resource_root: &Path,
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ use crate::localized_patch::{
|
|||||||
LOCALIZED_VERSIONS_DIR,
|
LOCALIZED_VERSIONS_DIR,
|
||||||
};
|
};
|
||||||
use crate::official_download::{
|
use crate::official_download::{
|
||||||
read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references,
|
official_distribution_publication_identity, read_cas_reuse_reference_manifest_at,
|
||||||
verify_official_distribution_publication_at, OfficialDownloadManifest,
|
read_download_manifest_at, read_official_distribution_attestation_at,
|
||||||
OfficialDownloadManifestEntry,
|
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::official_update::{read_version_state, OfficialVersionRecord, OfficialVersionState};
|
||||||
use crate::path_security::{
|
use crate::path_security::{
|
||||||
@@ -24,6 +26,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
const OFFICIAL_CURRENT_LINK: &str = "current";
|
const OFFICIAL_CURRENT_LINK: &str = "current";
|
||||||
const OFFICIAL_STAGING_DIR: &str = ".staging";
|
const OFFICIAL_STAGING_DIR: &str = ".staging";
|
||||||
@@ -148,6 +151,47 @@ pub struct ReleaseStatusReport {
|
|||||||
pub releases: Vec<ReleaseSummary>,
|
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.
|
/// A page of resources from a Rust-verified release choice.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct ReleaseDistributionPage {
|
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.
|
/// Lists one or both release namespaces.
|
||||||
pub fn build_release_list(
|
pub fn build_release_list(
|
||||||
official_root: &Path,
|
official_root: &Path,
|
||||||
@@ -1793,6 +2027,77 @@ mod tests {
|
|||||||
assert!(!official_blocked.available);
|
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]
|
#[test]
|
||||||
fn distribution_selection_does_not_fallback_from_damaged_localized_release() {
|
fn distribution_selection_does_not_fallback_from_damaged_localized_release() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1332,7 +1332,7 @@ func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunPa
|
|||||||
|
|
||||||
func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryConfirmParams) error {
|
func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryConfirmParams) error {
|
||||||
if strings.TrimSpace(params.RecordID) == "" || strings.TrimSpace(params.Reviewer) == "" {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-8
@@ -91,11 +91,17 @@ func fixtureRPCBackend(t *testing.T, root string) *fakeBackend {
|
|||||||
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
|
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
|
||||||
catalog: catalog,
|
catalog: catalog,
|
||||||
manifest: &backendrpc.ResourceManifestPage{
|
manifest: &backendrpc.ResourceManifestPage{
|
||||||
Available: true,
|
Available: true,
|
||||||
ResourceRoot: root,
|
Channel: "official",
|
||||||
ManifestVersion: 1,
|
ReleaseID: "official-fixture",
|
||||||
TotalEntries: len(manifestEntries),
|
ResourceRoot: root,
|
||||||
Entries: manifestEntries,
|
ManifestVersion: 1,
|
||||||
|
PublicationIdentity: "fixture-publication-v1",
|
||||||
|
MappingIdentity: "fixture-mapping-v1",
|
||||||
|
ManifestIdentity: "fixture-manifest-v1",
|
||||||
|
Generation: 1,
|
||||||
|
TotalEntries: len(manifestEntries),
|
||||||
|
Entries: manifestEntries,
|
||||||
},
|
},
|
||||||
releaseStatus: &backendrpc.ReleaseStatusReport{
|
releaseStatus: &backendrpc.ReleaseStatusReport{
|
||||||
Status: "ready",
|
Status: "ready",
|
||||||
@@ -821,13 +827,16 @@ type fakeBackend struct {
|
|||||||
statusCalls int
|
statusCalls int
|
||||||
doctorCalls int
|
doctorCalls int
|
||||||
releaseStatusCalls int
|
releaseStatusCalls int
|
||||||
|
attestationCalls int
|
||||||
status *backendrpc.DaemonStatusReport
|
status *backendrpc.DaemonStatusReport
|
||||||
doctor *backendrpc.DoctorReport
|
doctor *backendrpc.DoctorReport
|
||||||
releaseStatus *backendrpc.ReleaseStatusReport
|
releaseStatus *backendrpc.ReleaseStatusReport
|
||||||
releaseStatusErr error
|
releaseStatusErr error
|
||||||
|
attestation *backendrpc.DistributionAttestation
|
||||||
catalog json.RawMessage
|
catalog json.RawMessage
|
||||||
resource *backendrpc.ResourceState
|
resource *backendrpc.ResourceState
|
||||||
manifest *backendrpc.ResourceManifestPage
|
manifest *backendrpc.ResourceManifestPage
|
||||||
|
manifestParams []backendrpc.ResourceManifestParams
|
||||||
daemonLogs *backendrpc.LogsReport
|
daemonLogs *backendrpc.LogsReport
|
||||||
taskList *backendrpc.TaskList
|
taskList *backendrpc.TaskList
|
||||||
taskStatus *backendrpc.TaskRecord
|
taskStatus *backendrpc.TaskRecord
|
||||||
@@ -858,6 +867,77 @@ func (f *fakeBackend) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseSta
|
|||||||
OfficialDistributionReady: true,
|
OfficialDistributionReady: true,
|
||||||
}, nil
|
}, 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) {
|
func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
|
||||||
if f.resource != nil {
|
if f.resource != nil {
|
||||||
return 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) {
|
func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
return f.catalog, nil
|
return f.catalog, nil
|
||||||
}
|
}
|
||||||
func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
|
func (f *fakeBackend) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
|
||||||
return f.manifest, nil
|
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) {
|
func (f *fakeBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
|
||||||
if f.daemonLogs != nil {
|
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) {
|
func (p *pollingBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
return nil, errors.New("unexpected catalog call")
|
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")
|
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) {
|
func TestStartRefreshLoopPollsBackend(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|||||||
@@ -332,7 +332,8 @@ BAT_API_STATE_DIR=/tmp/bat-pid
|
|||||||
# BAT_API_SOCKET=/tmp/bat-pid/bat.sock
|
# BAT_API_SOCKET=/tmp/bat-pid/bat.sock
|
||||||
|
|
||||||
# Optional override of the published release root (fixtures / emergency only).
|
# 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=
|
# BAT_API_RESOURCE_ROOT=
|
||||||
|
|
||||||
# Optional server-info JSON for Addressables root rewrite
|
# Optional server-info JSON for Addressables root rewrite
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
|
|||||||
availableRaw := readContractFixture(t, "catalog-status.available.json")
|
availableRaw := readContractFixture(t, "catalog-status.available.json")
|
||||||
unavailableRaw := readContractFixture(t, "catalog-status.unavailable.json")
|
unavailableRaw := readContractFixture(t, "catalog-status.unavailable.json")
|
||||||
manifestRaw := readContractFixture(t, "resource-manifest.page0.json")
|
manifestRaw := readContractFixture(t, "resource-manifest.page0.json")
|
||||||
|
attestationRaw := readContractFixture(t, "release-attestation.json")
|
||||||
snapshotRaw := readContractFixture(t, "official-sync-snapshot.json")
|
snapshotRaw := readContractFixture(t, "official-sync-snapshot.json")
|
||||||
|
|
||||||
for name, raw := range map[string][]byte{
|
for name, raw := range map[string][]byte{
|
||||||
"catalog available": availableRaw,
|
"catalog available": availableRaw,
|
||||||
"catalog unavailable": unavailableRaw,
|
"catalog unavailable": unavailableRaw,
|
||||||
"resource manifest": manifestRaw,
|
"resource manifest": manifestRaw,
|
||||||
|
"attestation": attestationRaw,
|
||||||
"snapshot": snapshotRaw,
|
"snapshot": snapshotRaw,
|
||||||
} {
|
} {
|
||||||
if bytes.Contains(raw, []byte("/tmp/")) {
|
if bytes.Contains(raw, []byte("/tmp/")) {
|
||||||
@@ -71,7 +73,12 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
|
|||||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||||
t.Fatalf("decode resource manifest: %v", err)
|
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)
|
t.Fatalf("manifest header=%+v", manifest)
|
||||||
}
|
}
|
||||||
if len(manifest.Entries) != 2 {
|
if len(manifest.Entries) != 2 {
|
||||||
@@ -84,6 +91,18 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
|
|||||||
t.Fatalf("manifest first entry=%+v", manifest.Entries[0])
|
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 {
|
var snapshot struct {
|
||||||
AppVersion string `json:"app_version"`
|
AppVersion string `json:"app_version"`
|
||||||
BundleVersion string `json:"bundle_version"`
|
BundleVersion string `json:"bundle_version"`
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ paths:
|
|||||||
summary: Release readiness
|
summary: Release readiness
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"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":
|
"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:
|
/v1/bootstrap:
|
||||||
get:
|
get:
|
||||||
summary: Startup resource bootstrap
|
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.
|
// DistributionHealth is the release-level authorization used by read paths.
|
||||||
//
|
//
|
||||||
// In RPC mode Ready is copied from Rust's release.status
|
// In RPC mode these fields are copied from Rust's current official
|
||||||
// official_distribution_ready fact. The local manifest checks only establish
|
// attestation. The local manifest checks only establish that this process has
|
||||||
// that this process has a usable read snapshot; they do not replace Rust's
|
// a complete, safe read snapshot; they do not replace Rust's verifier.
|
||||||
// release verifier.
|
|
||||||
type DistributionHealth struct {
|
type DistributionHealth struct {
|
||||||
Ready bool `json:"ready"`
|
Ready bool `json:"ready"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Status string `json:"status,omitempty"`
|
Channel string `json:"channel,omitempty"`
|
||||||
StatusCode string `json:"status_code,omitempty"`
|
ReleaseID string `json:"release_id,omitempty"`
|
||||||
IntegrityStatus string `json:"integrity_status,omitempty"`
|
ResourceRoot string `json:"resource_root,omitempty"`
|
||||||
Diagnostics []string `json:"diagnostics,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.
|
// ReleaseIndex is the in-memory view of a published resource root.
|
||||||
@@ -122,7 +130,11 @@ func (idx *ReleaseIndex) Summary() ReleaseSummary {
|
|||||||
if distribution.Source == "" {
|
if distribution.Source == "" {
|
||||||
distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest"
|
distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest"
|
||||||
} else {
|
} else {
|
||||||
distribution.Ready = distribution.Ready && localComplete
|
distribution.Ready = distribution.Ready &&
|
||||||
|
localComplete &&
|
||||||
|
(idx.Source != "rpc" ||
|
||||||
|
(distribution.ManifestIdentity != "" &&
|
||||||
|
distribution.EntryCount == len(idx.Entries)))
|
||||||
}
|
}
|
||||||
return ReleaseSummary{
|
return ReleaseSummary{
|
||||||
ResourceRoot: idx.ResourceRoot,
|
ResourceRoot: idx.ResourceRoot,
|
||||||
|
|||||||
+161
-61
@@ -12,16 +12,18 @@ import (
|
|||||||
|
|
||||||
// Backend is the subset of daemon RPC used by bat-api.
|
// 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
|
// 1. daemon.status
|
||||||
// 2. daemon.doctor
|
// 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 {
|
type Backend interface {
|
||||||
DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error)
|
DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error)
|
||||||
DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error)
|
DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error)
|
||||||
ResourceState(ctx context.Context) (*backendrpc.ResourceState, error)
|
ResourceState(ctx context.Context) (*backendrpc.ResourceState, error)
|
||||||
CatalogStatus(ctx context.Context) (json.RawMessage, 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
|
// 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) {
|
func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
return r.Client.CatalogStatus(ctx)
|
return r.Client.CatalogStatus(ctx)
|
||||||
}
|
}
|
||||||
func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
|
func (r RPCClient) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
|
||||||
return r.Client.ResourceManifest(ctx, offset, limit)
|
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) {
|
func (r RPCClient) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) {
|
||||||
return r.Client.DaemonRestart(ctx)
|
return r.Client.DaemonRestart(ctx)
|
||||||
@@ -310,6 +315,7 @@ type DiscoverResult struct {
|
|||||||
DoctorHealthy *bool
|
DoctorHealthy *bool
|
||||||
Status *backendrpc.DaemonStatusReport
|
Status *backendrpc.DaemonStatusReport
|
||||||
Doctor *backendrpc.DoctorReport
|
Doctor *backendrpc.DoctorReport
|
||||||
|
Attestation *backendrpc.DistributionAttestation
|
||||||
ReleaseStatus *backendrpc.ReleaseStatusReport
|
ReleaseStatus *backendrpc.ReleaseStatusReport
|
||||||
Distribution DistributionHealth
|
Distribution DistributionHealth
|
||||||
Snapshot *SnapshotSummary
|
Snapshot *SnapshotSummary
|
||||||
@@ -318,10 +324,11 @@ type DiscoverResult struct {
|
|||||||
Warnings []string
|
Warnings []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiscoverAndIndex contacts the daemon (status first, then doctor, then
|
// DiscoverAndIndex contacts the daemon (status first, then doctor, then the
|
||||||
// release.status) and builds a release index from paginated resource.manifest
|
// lightweight current-release attestation) and builds a release index from
|
||||||
// plus on-disk checks. Rust's release.status is the only release-level
|
// pages bound to that attestation plus on-disk checks. Rust's attestation is
|
||||||
// integrity authorization used for the production RPC path.
|
// 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
|
// If resourceRootOverride is non-empty, it wins over RPC-reported roots after
|
||||||
// RPC health probes (still preferred for production to call status/doctor).
|
// 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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) release.status is the Rust-owned whole-release distribution gate.
|
// 3) release.attestation is the Rust-owned current-release distribution gate.
|
||||||
releaseStatusBackend, ok := backend.(ReleaseStatusBackend)
|
attestationBackend, ok := backend.(AttestationBackend)
|
||||||
if !ok {
|
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"),
|
return emptyRPCResult(out, nil, "Rust release health is unavailable"),
|
||||||
fmt.Errorf("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 {
|
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"),
|
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 {
|
if attestation == nil {
|
||||||
out.Warnings = append(out.Warnings, "release.status: empty response")
|
out.Warnings = append(out.Warnings, "release.attestation: empty response")
|
||||||
return emptyRPCResult(out, nil, "Rust release health query returned no 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
|
// Catalog / resource discovery
|
||||||
var snapshot *SnapshotSummary
|
var snapshot *SnapshotSummary
|
||||||
var resourceRoot string
|
resourceRoot := attestation.ResourceRoot
|
||||||
catalogAvailabilityKnown := false
|
catalogAvailabilityKnown := false
|
||||||
catalogAvailable := false
|
catalogAvailable := false
|
||||||
|
|
||||||
@@ -429,18 +439,31 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
|||||||
}
|
}
|
||||||
if snapshot != nil &&
|
if snapshot != nil &&
|
||||||
snapshot.VersionID != "" &&
|
snapshot.VersionID != "" &&
|
||||||
out.ReleaseStatus.OfficialCurrentReleaseID != "" &&
|
attestation.ReleaseID != "" &&
|
||||||
snapshot.VersionID != out.ReleaseStatus.OfficialCurrentReleaseID {
|
snapshot.VersionID != attestation.ReleaseID {
|
||||||
return emptyRPCResult(
|
return emptyRPCResult(
|
||||||
out,
|
out,
|
||||||
snapshot,
|
snapshot,
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"release.status current ID %q does not match catalog current ID %q",
|
"release.attestation current ID %q does not match catalog current ID %q",
|
||||||
out.ReleaseStatus.OfficialCurrentReleaseID,
|
attestation.ReleaseID,
|
||||||
snapshot.VersionID,
|
snapshot.VersionID,
|
||||||
),
|
),
|
||||||
), nil
|
), 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 {
|
if catalogAvailabilityKnown && !catalogAvailable {
|
||||||
return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil
|
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 {
|
if err != nil {
|
||||||
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
|
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
|
||||||
// Without the RPC manifest there is no evidence that the local
|
// Without the RPC manifest there is no evidence that the local
|
||||||
@@ -498,6 +521,17 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
|||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
if rootFromManifest != "" {
|
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
|
resourceRoot = rootFromManifest
|
||||||
}
|
}
|
||||||
if resourceRootOverride != "" {
|
if resourceRootOverride != "" {
|
||||||
@@ -530,6 +564,13 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
|||||||
return out, nil
|
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 {
|
func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult {
|
||||||
distribution := out.Distribution
|
distribution := out.Distribution
|
||||||
if distribution.Source == "" {
|
if distribution.Source == "" {
|
||||||
@@ -553,52 +594,48 @@ func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning stri
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func rustDistributionHealth(report *backendrpc.ReleaseStatusReport) DistributionHealth {
|
func rustAttestationHealth(report *backendrpc.DistributionAttestation) DistributionHealth {
|
||||||
health := DistributionHealth{
|
health := DistributionHealth{
|
||||||
Source: "rust_release_status",
|
Source: "rust_release_attestation",
|
||||||
Status: "blocked",
|
Status: "unavailable",
|
||||||
StatusCode: "distribution.blocked",
|
StatusCode: "distribution.attestation_unavailable",
|
||||||
IntegrityStatus: "unknown",
|
IntegrityStatus: "unavailable",
|
||||||
}
|
}
|
||||||
if report == nil {
|
if report == nil {
|
||||||
return health
|
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.Status = report.Status
|
||||||
health.StatusCode = report.StatusCode
|
health.StatusCode = report.StatusCode
|
||||||
|
health.IntegrityStatus = report.IntegrityStatus
|
||||||
|
health.Diagnostics = append([]string(nil), report.Diagnostics...)
|
||||||
if health.Status == "" {
|
if health.Status == "" {
|
||||||
if health.Ready {
|
health.Status = "unavailable"
|
||||||
health.Status = "ready"
|
|
||||||
} else {
|
|
||||||
health.Status = "blocked"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if health.StatusCode == "" {
|
if health.StatusCode == "" {
|
||||||
if health.Ready {
|
health.StatusCode = "distribution.attestation_unavailable"
|
||||||
health.StatusCode = "distribution.ready"
|
|
||||||
} else {
|
|
||||||
health.StatusCode = "distribution.blocked"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, release := range report.Releases {
|
if health.IntegrityStatus == "" {
|
||||||
if release.Channel == "official" && release.Current {
|
health.IntegrityStatus = "unavailable"
|
||||||
health.IntegrityStatus = release.DistributionIntegrityStatus
|
|
||||||
health.Diagnostics = append([]string(nil), release.Diagnostics...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if health.IntegrityStatus == "valid" && !health.Ready {
|
|
||||||
health.IntegrityStatus = "invalid"
|
|
||||||
}
|
}
|
||||||
return health
|
return health
|
||||||
}
|
}
|
||||||
|
|
||||||
func unavailableRustDistributionHealth() DistributionHealth {
|
func unavailableRustDistributionHealth() DistributionHealth {
|
||||||
return DistributionHealth{
|
return DistributionHealth{
|
||||||
Source: "rust_release_status",
|
Source: "rust_release_attestation",
|
||||||
Status: "unavailable",
|
Status: "unavailable",
|
||||||
StatusCode: "distribution.health_unavailable",
|
StatusCode: "distribution.attestation_unavailable",
|
||||||
IntegrityStatus: "unknown",
|
IntegrityStatus: "unavailable",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,25 +712,85 @@ func parseCatalogAvailability(raw json.RawMessage) (bool, bool) {
|
|||||||
return *payload.Available, true
|
return *payload.Available, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEntry, int, string, error) {
|
func fetchAllManifestEntries(
|
||||||
const pageSize = 500
|
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
|
offset := 0
|
||||||
var all []manifestEntry
|
var all []manifestEntry
|
||||||
var version int
|
var version int
|
||||||
var root string
|
var root, releaseID, publicationIdentity, mappingIdentity, manifestIdentity string
|
||||||
|
total := -1
|
||||||
for {
|
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 {
|
if err != nil {
|
||||||
return nil, 0, "", err
|
return nil, 0, "", err
|
||||||
}
|
}
|
||||||
|
if page == nil {
|
||||||
|
return nil, 0, "", fmt.Errorf("resource.manifest returned nil page")
|
||||||
|
}
|
||||||
if !page.Available {
|
if !page.Available {
|
||||||
return nil, 0, "", fmt.Errorf("resource.manifest available=false")
|
return nil, 0, "", fmt.Errorf("resource.manifest available=false")
|
||||||
}
|
}
|
||||||
if root == "" {
|
if root == "" {
|
||||||
root = page.ResourceRoot
|
root = page.ResourceRoot
|
||||||
}
|
releaseID = page.ReleaseID
|
||||||
if version == 0 {
|
publicationIdentity = page.PublicationIdentity
|
||||||
|
mappingIdentity = page.MappingIdentity
|
||||||
|
manifestIdentity = page.ManifestIdentity
|
||||||
version = page.ManifestVersion
|
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 {
|
for _, e := range page.Entries {
|
||||||
var bytes uint64
|
var bytes uint64
|
||||||
@@ -708,9 +805,12 @@ func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEn
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
offset += len(page.Entries)
|
offset += len(page.Entries)
|
||||||
if len(page.Entries) == 0 || offset >= page.TotalEntries {
|
if offset == total {
|
||||||
break
|
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
|
return all, version, root, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -7,7 +7,9 @@ contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本
|
|||||||
覆盖范围:
|
覆盖范围:
|
||||||
|
|
||||||
- `catalog.status` 可用与不可用响应。
|
- `catalog.status` 可用与不可用响应。
|
||||||
- `resource.manifest` 第一页分页响应。
|
- `resource.manifest` 第一页分页响应,包含 release/publication/mapping/manifest
|
||||||
|
identity 和 generation 绑定字段。
|
||||||
|
- `release.attestation` 当前 official health/publication proof。
|
||||||
- 对应 release 的 `official-sync-snapshot.json`。
|
- 对应 release 的 `official-sync-snapshot.json`。
|
||||||
- `launcher_metadata` 与 `game_main_config_bootstrap` 的 Go mirror 解码。
|
- `launcher_metadata` 与 `game_main_config_bootstrap` 的 Go mirror 解码。
|
||||||
- Rust Glossary V1 query 响应,覆盖 alias、approved review、source provenance 和完整 history。
|
- 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,
|
"available": true,
|
||||||
|
"channel": "official",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"blake3": "0000000000000000000000000000000000000000000000000000000000000000",
|
"blake3": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
@@ -14,9 +15,14 @@
|
|||||||
"url": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.hash"
|
"url": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.hash"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"generation": 7,
|
||||||
"limit": 2,
|
"limit": 2,
|
||||||
|
"manifest_identity": "${MANIFEST_IDENTITY}",
|
||||||
"manifest_version": 1,
|
"manifest_version": 1,
|
||||||
|
"mapping_identity": "${MAPPING_IDENTITY}",
|
||||||
"offset": 0,
|
"offset": 0,
|
||||||
|
"publication_identity": "${PUBLICATION_IDENTITY}",
|
||||||
|
"release_id": "${VERSION_ID}",
|
||||||
"resource_root": "${RESOURCE_ROOT}",
|
"resource_root": "${RESOURCE_ROOT}",
|
||||||
"total_entries": 2
|
"total_entries": 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ func (c *Client) callEnvelope(ctx context.Context, method string, params any) (*
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
if deadline, ok := c.deadline(ctx); ok {
|
if deadline, ok := c.deadline(ctx); ok {
|
||||||
_ = conn.SetDeadline(deadline)
|
_ = conn.SetDeadline(deadline)
|
||||||
@@ -877,14 +877,29 @@ type ResourceManifestEntry struct {
|
|||||||
BLAKE3 string `json:"blake3,omitempty"`
|
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 {
|
type ResourceManifestPage struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
ResourceRoot string `json:"resource_root,omitempty"`
|
Channel string `json:"channel,omitempty"`
|
||||||
ManifestVersion int `json:"manifest_version,omitempty"`
|
ReleaseID string `json:"release_id,omitempty"`
|
||||||
TotalEntries int `json:"total_entries,omitempty"`
|
ResourceRoot string `json:"resource_root,omitempty"`
|
||||||
Offset int `json:"offset,omitempty"`
|
ManifestVersion int `json:"manifest_version,omitempty"`
|
||||||
Limit int `json:"limit,omitempty"`
|
PublicationIdentity string `json:"publication_identity,omitempty"`
|
||||||
Entries []ResourceManifestEntry `json:"entries,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) {
|
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
|
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
|
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
|
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) {
|
func TestUnityFSPatchFieldSendsTaggedReplacement(t *testing.T) {
|
||||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
if req.Method != "unityfs.patch_field" {
|
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" "daemon.clean-stable"
|
||||||
require_contains "docs/reference/rpc-backend-api.md" "localized_release_status"
|
require_contains "docs/reference/rpc-backend-api.md" "localized_release_status"
|
||||||
require_contains "docs/reference/rpc-backend-api.md" "official_distribution_ready"
|
require_contains "docs/reference/rpc-backend-api.md" "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" "check-docs:"
|
||||||
require_contains "Makefile" "format: fmt"
|
require_contains "Makefile" "format: fmt"
|
||||||
require_contains "Makefile" "ci-check:"
|
require_contains "Makefile" "ci-check:"
|
||||||
require_contains "Makefile" "ci: ci-check"
|
require_contains "Makefile" "ci: ci-check"
|
||||||
require_file "scripts/ci-check.sh"
|
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" "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
|
if grep -Fq "ci: fmt" Makefile; then
|
||||||
fail "Makefile ci target must not run the mutating fmt target"
|
fail "Makefile ci target must not run the mutating fmt target"
|
||||||
fi
|
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 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" "go build -o /tmp/bat-api ./cmd/bat-api"
|
||||||
require_contains ".gitea/workflows/bat.yml" "Check Go formatting"
|
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"
|
require_contains "Makefile" "cargo clippy --workspace --all-targets -- -D warnings"
|
||||||
|
|
||||||
openapi_tmp="$(mktemp)"
|
openapi_tmp="$(mktemp)"
|
||||||
|
|||||||
+16
-19
@@ -9,6 +9,9 @@ export GOCACHE
|
|||||||
: "${XDG_CACHE_HOME:=/tmp/bat-xdg-cache}"
|
: "${XDG_CACHE_HOME:=/tmp/bat-xdg-cache}"
|
||||||
export XDG_CACHE_HOME
|
export XDG_CACHE_HOME
|
||||||
|
|
||||||
|
source "${repo_root}/scripts/ci-versions.sh"
|
||||||
|
export GOLANGCI_LINT_VERSION
|
||||||
|
|
||||||
run_required() {
|
run_required() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
shift
|
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 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 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 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
|
run_required "Documentation, OpenAPI, and contract checks" make check-docs
|
||||||
|
|
||||||
optional_failed=0
|
printf 'all required check-only gates passed\n'
|
||||||
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'
|
|
||||||
|
|||||||
@@ -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