Compare commits

...
16 Commits
Author SHA1 Message Date
nyaKazuha ff1adb91ee fix(tm):建立 Trusted 唯一性与 Supersede 治理 2026-09-18 06:44:14 +08:00
nyaKazuha e486f1aaaa fix(glossary): 修复历史 V1 漂移并建立 V2 迁移
bat-rust / Build and test Rust (push) Waiting to run
bat-rust / Build and test Go API (push) Waiting to run
2026-09-17 21:25:29 +08:00
nyaKazuha 7f7d757f15 fix(sqlite): 收口长期状态数据库版本化迁移契约
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-16 21:02:22 +08:00
nyaKazuha 99355effe4 fix(release): 完成分发证明代际绑定与质量门禁收口
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-15 21:13:52 +08:00
nyaKazuha 13b0bd5b45 fix(release):完善当前分发证明与质量门禁
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-14 06:39:47 +08:00
nyaKazuha c17904ee1c fix(repo):统一发布健康与只读质量门禁
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-13 22:09:44 +08:00
nyaKazuha 32fc64fa83 docs(repo):完善协作规则与任务账本
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-13 18:33:08 +08:00
nyaKazuha 37d49c9793 fix(release): 完成官方发布身份与 legacy CAS 归属保护
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-13 15:34:42 +08:00
nyaKazuha 5bae90cb14 fix(release): 完成分发身份与 CAS legacy ownership 收尾
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-13 10:44:24 +08:00
nyaKazuha 786b739f99 fix(release): 收紧分发热路径与事务边界
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-12 22:22:11 +08:00
nyaKazuha f2c20367a6 fix(release): 完善发布事务和状态隔离
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-12 17:50:09 +08:00
nyaKazuha 30d1cd77e8 fix(release): 修复发布一致性与并发边界
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-12 16:35:55 +08:00
nyaKazuha 8d57a63697 feat(release):完成双 release 运维闭环
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-12 11:08:04 +08:00
nyaKazuha 8a77502272 feat(patch):统一清单驱动汉化发布
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-12 01:35:29 +08:00
nyaKazuha 69b6e36bf0 feat(assetbundle): 完成已验证结构的重建发布闭环
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-10 00:46:48 +08:00
nyaKazuha 68c6c91b1e fix(glossary): 绑定 QA identity 与人工 override
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-09 00:25:49 +08:00
88 changed files with 21736 additions and 1563 deletions
-247
View File
@@ -1,247 +0,0 @@
# Gitea Actions workflow for the Rust workspace.
# Self-hosted runner friendly.
# Does not use external GitHub Actions.
name: bat-rust
on:
push:
branches:
- "**"
pull_request:
workflow_dispatch:
jobs:
rust:
name: Build and test Rust
runs-on: linux
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
BAT_SKIP_ENV_FILE: "1"
steps:
- name: Checkout
shell: bash
run: |
set -euo pipefail
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
: "${GITHUB_SHA:?GITHUB_SHA is required}"
repo_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
if [ -d .git ]; then
git remote set-url origin "${repo_url}"
else
git init .
git remote add origin "${repo_url}"
fi
ref="${GITHUB_REF:-${GITHUB_SHA}}"
git fetch \
--no-tags \
--depth=1 \
origin \
"${ref}" \
|| git fetch \
--no-tags \
--depth=1 \
origin \
"${GITHUB_SHA}"
git checkout --force --detach FETCH_HEAD
git submodule update --init --recursive
- name: Show tool versions
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
command -v git
command -v rustc
command -v cargo
rustc --version
cargo --version
rustfmt --version
cargo clippy --version
- name: Check formatting
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo fmt --all -- --check
- name: Check workspace
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo check --workspace --locked
- name: Build workspace
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo build \
--workspace \
--release \
--locked
- name: Run clippy
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo clippy \
--workspace \
--all-targets \
--locked \
-- -D warnings
- name: Run tests
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo test \
--workspace \
--locked
- name: Package binary
shell: bash
run: |
set -euo pipefail
mkdir -p artifact
cp target/release/bat artifact/
tar \
-czf \
bat-linux-x86_64.tar.gz \
-C artifact \
bat
sha256sum \
bat-linux-x86_64.tar.gz \
> bat-linux-x86_64.sha256
- name: Prepare artifact
shell: bash
run: |
set -euo pipefail
mkdir upload
cp bat-linux-x86_64.tar.gz upload/
cp bat-linux-x86_64.sha256 upload/
cd upload
zip -q \
../bat-linux-x86_64.zip \
*
cd ..
ls -lh bat-linux-x86_64.zip
go-api:
name: Build and test Go API
runs-on: linux
env:
GOCACHE: /tmp/bat-go-cache
steps:
- name: Checkout
shell: bash
run: |
set -euo pipefail
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
: "${GITHUB_SHA:?GITHUB_SHA is required}"
repo_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
if [ -d .git ]; then
git remote set-url origin "${repo_url}"
else
git init .
git remote add origin "${repo_url}"
fi
ref="${GITHUB_REF:-${GITHUB_SHA}}"
git fetch \
--no-tags \
--depth=1 \
origin \
"${ref}" \
|| git fetch \
--no-tags \
--depth=1 \
origin \
"${GITHUB_SHA}"
git checkout --force --detach FETCH_HEAD
git submodule update --init --recursive
- name: Show Go tool version
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
command -v go
go version
- name: Run Go API tests
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
make test-go-api
- name: Run Go API vet
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
- name: Build Go API
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
go build -o /tmp/bat-api ./cmd/bat-api
- name: Run documentation status gate
shell: bash
run: |
set -euo pipefail
make check-docs
+118 -4
View File
@@ -2,7 +2,7 @@
本文件用于约束在 BlueArchiveToolkit 中工作的 AI Agent。
具体开发进度看 `CURRENT_STATUS.md`,开发计划看 `PROJECT_PLAN.md`,当前缺口看 `docs/reports/CURRENT_GAPS.md`。这里不记录具体任务和阶段待办。
具体开发进度看 `CURRENT_STATUS.md`,开发计划看 `PROJECT_PLAN.md`,当前能力缺口看 `docs/reports/CURRENT_GAPS.md`,具体工程任务、优先级和依赖看根目录 `TODO.md`。这里不记录具体任务和阶段待办。
## 基本要求
@@ -14,6 +14,18 @@ BlueArchiveToolkit 是长期维护项目。不要为了尽快完成当前任务
如果发现用户提出的方案、现有代码或文档本身有问题,直接指出。不要为了迎合要求保留明显不合理的设计。
## 工程修改原则
BlueArchiveToolkit 不以“最小修复”为工程目标。不要为了让单个 testcase 通过、暂时消除表面症状或缩小 diff,而留下已经能够确认的同根因问题。
处理问题时优先保证长期可维护性、可用性、安全性、明确契约、恢复能力和回归覆盖。进入一个工程边界后,应根据实际相关性检查正常路径、异常路径、并发、重试、恢复、兼容、持久化和资源限制,并把属于同一 root cause 或同一 contract 的问题完整收口。
这不意味着无边界重构。不要为了架构形式、代码行数或“以后也许会用”扩大修改范围;与当前 contract 无关的问题应记录到 `TODO.md`,留给后续独立处理。
跨模块问题必须沿真实状态所有权和调用链检查。例如 Rust 状态经 RPC 暴露给 Go,再由 HTTP 或 Web 消费时,不能只修改其中一层而让其他层继续保持矛盾语义。
持久化和状态机修改应考虑 schema/version、transaction、crash consistency、retry、recovery 与兼容读取;解析器、压缩包和其他外部输入应考虑 size/count/depth 等资源边界以及 malformed input 的确定性失败。
## 以什么为准
仓库里有不少历史文档,不能混着看。
@@ -71,6 +83,96 @@ Go `bat-api` 是资源 bootstrap、只读分发和管理入口。它通过 `bat.
不要静默改变已有字段的含义。确实需要破坏性修改时,先考虑版本号、迁移或兼容读取。
## Dashboard 开发与设计
BlueArchiveToolkit 包含两个面向不同使用者的 Dashboard:用户 Dashboard 与运营 Dashboard。两者属于同一产品,应共享基础视觉语言、组件风格和交互一致性,但不得因为复用组件而混淆产品职责、信息层级或权限边界。
涉及 Dashboard、Web UI、页面布局、视觉样式、组件设计或交互体验的任务,在开始设计和修改前必须阅读仓库根目录的 `DESIGN.md`
`DESIGN.md` 是 Dashboard 的主要视觉参考与设计灵感来源。应理解并延续其中的色彩关系、排版、空间、边框、层级、组件形态和交互气质,但不得机械复制其来源产品的页面结构、品牌内容或不适合 BlueArchiveToolkit 的设计。实际页面的信息架构始终由 BlueArchiveToolkit 当前功能、真实数据结构和使用场景决定。
### 用户 Dashboard
用户 Dashboard 面向普通 BlueArchiveToolkit 用户,目标是以尽可能低的认知负担完成与汉化相关的用户操作。
当前用户可控制的核心能力仅包括:
* 文字汉化是否启用;
* 图像汉化是否启用。
用户端可以展示与这些操作直接相关的必要信息,例如汉化状态、当前可用版本、更新状态、操作反馈或用户需要处理的异常,但不得暴露内部运维实现。
除非未来产品需求明确改变,否则用户 Dashboard 不应展示或要求用户理解:
* `bat` / `bat-api` 内部状态;
* RPC、daemon、worker
* CAS
* Provider / provider run
* Translation Memory 内部记录;
* translation task
* Parser
* official/localized release 的内部实现细节;
* 服务端日志、内部错误栈和运维指标。
用户端优先保证清晰、简洁、可信和易操作。不要为了表现“Dashboard 感”堆积 KPI 卡片、图表、技术指标或无实际用途的信息。
### 运营 Dashboard
运营 Dashboard 面向项目运营和维护者,用于观察和管理 BlueArchiveToolkit 的真实运行状态。
运营端可以根据当前后端实际提供的 contract 展示和组织:
* `bat``bat-api` 运行状态;
* official resource / official release
* localized resource / localized release
* 资源同步与更新状态;
* Translation / Translation Memory
* Provider 与 worker
* task / job
* daemon/runtime
* CAS
* 错误、诊断与日志;
* 配置和必要的运营操作。
运营 Dashboard 是高信息密度的 developer/operations interface。优先使用结构化列表、表格、紧凑状态信息、清晰的主次层级和按需 drill-down,而不是将所有数据做成大型 Card。
首页应帮助运营者快速回答“系统是否正常、哪里需要处理、最近发生了什么”,而不是简单罗列所有可获得的指标。
### 两个 Dashboard 的关系
两个 Dashboard 应共享:
* 基础 Design Token
* Typography
* Color System
* Button、Input、Switch、Dialog 等基础组件;
* Loading、Empty、Error、Warning、Success 等状态语言;
* Motion 与交互反馈原则;
* 品牌识别。
但可以拥有不同的:
* Navigation
* 页面结构;
* 信息密度;
* 内容层级;
* 默认组件尺寸;
* 数据展示方式。
不要把运营 Dashboard 简单裁剪几个菜单后作为用户 Dashboard,也不要为了用户端的简洁限制运营端所需的信息密度。
### 设计实现原则
Dashboard 设计必须以真实接口和真实状态为依据。不得为了视觉完整性伪造后端不存在的数据、指标、趋势、操作或状态。
如果设计需要当前 API/RPC 尚未提供的信息,应明确指出缺失 contract,而不是在前端维护第二份业务状态或通过猜测拼接数据。
优先复用项目现有前端组件和设计基础。引入新组件模式前先确认现有组件无法合理满足需求,避免同一项目逐步形成多套 Card、Table、Badge、Button 或状态展示体系。
`DESIGN.md` 是视觉方向,不高于项目稳定架构与产品事实。发生冲突时按以下优先级处理:
`AGENTS.md` 与稳定产品/接口契约 > 当前明确任务需求 > `DESIGN.md` > Agent 自身设计偏好。
## 代码修改
先弄清楚代码为什么放在当前位置,再决定是继续修改还是拆模块。
@@ -86,9 +188,19 @@ Go `bat-api` 是资源 bootstrap、只读分发和管理入口。它通过 `bat.
* 无说明的硬编码;
* 魔法数字;
* 假实现、空实现冒充完成功能;
*`TODO` / `FIXME` 代替正式缺口记录。
*代码内 `TODO` / `FIXME` 代替根目录 `TODO.md``CURRENT_GAPS.md` 或其他正式缺口记录。
如果当前任务确实无法完成某一部分,应明确限制实现范围,并把剩余问题记录到对应的状态、缺口或 Issue
如果当前任务确实无法完成某一部分,应明确限制实现范围;具体后续工程任务记录到根目录 `TODO.md`,能力缺口同步到 `CURRENT_GAPS.md`,需要外部协作时再使用 Issue。
## TODO 任务治理
根目录 `TODO.md` 是具体工程任务、优先级、依赖关系和完成条件的仓库内任务账本。开始具体开发前,应读取与当前工作相关的 TODO;完成任务或发现独立新问题后,应同步更新其状态和依赖。
`TODO.md` 不是当前实现事实来源。源码和测试、`CURRENT_STATUS.md`、专项 current-status 文档以及稳定 contract 的优先级高于 TODO 描述。若 TODO 与当前实现冲突,应先核对事实并更新过时 TODO,不要按照旧条目重新实现已经完成的能力。
属于当前任务同一 root cause 或同一 contract 的已确认问题,不得仅为了缩小 patch 而登记 TODO 后绕过;应在当前工程边界内一起收口。明显独立的问题应记录到 `TODO.md`,避免当前修改无限扩张。
`docs/reports/CURRENT_GAPS.md` 用于记录产品或工程能力层面的当前缺口;`PROJECT_PLAN.md` 用于长期路线;`TODO.md` 用于可执行任务追踪。不要把这些职责混在一起。
## 文件、网络和发布安全
@@ -145,7 +257,9 @@ make check-docs
不要把具体任务、临时优先级或某次实现方案写进本文件。
新的长期架构决策应该进入 ADR 或对应架构文档;开发路线进入 `PROJECT_PLAN.md`;实际进度进入 `CURRENT_STATUS.md`未完成内容进入 `CURRENT_GAPS.md` Issue。
新的长期架构决策应该进入 ADR 或对应架构文档;开发路线进入 `PROJECT_PLAN.md`;实际进度进入 `CURRENT_STATUS.md`能力缺口进入 `docs/reports/CURRENT_GAPS.md`;具体工程任务、依赖和完成条件进入根目录 `TODO.md`;需要外部协作时再使用 Issue。
Dashboard 的视觉方向与设计灵感进入根目录 `DESIGN.md`;Dashboard 的产品职责、状态所有权和接口事实仍以本文件与稳定产品/接口契约为准。
## 工作方式
+68 -23
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 当前工作区状态
- **更新时间**2026-09-08
- **更新时间**2026-09-13
- **状态来源**:本地工作区盘点、代码验证和最新提交
- **状态分支**`experiment`
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
@@ -30,21 +30,62 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root``--import-resource-db``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,release、平台、bundle path 和常用数组 metadata 过滤已下推到 SQLite,数据库不存在时返回 `available=false` 且不会创建空库;`bat doctor cas` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计,不会因诊断创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units``parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixtureup-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published``status_code=localized.published``localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`bat` 首次启动会在二进制所在目录释放 `config.toml.example` 配置模板(`0600`),`config.toml` 存在且 Unix 权限为 `0600` 或更严格时读取并使用它;`config.toml` 不存在时仅保留模板,不自动读取 example,运行时继续使用环境变量和内置默认值。优先级为命令行参数 > 进程环境变量 > `config.toml` > 内置默认值,`BAT_SKIP_ENV_FILE` 已废弃且不再影响启动;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。官方同步报告还分别统计当前 manifest 复用、历史 release 复用、CAS 复用、网络传输字节和复用回退诊断,下载事件状态使用 `release_reused``cas_reused``downloaded` 等稳定值。
15. Rust `bat` 已提供 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。`translation.worker.run` 已接入 provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。`bat-api` 已提供内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该工作流只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作。真实官方网络全量拉取 smoke 已固化,真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`
15. Rust `bat` 已提供 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、generic manifest 驱动的 Binary/JSON/Text/受支持 UnityFS 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。`translation.worker.run` 已接入 provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。`bat-api` 已提供内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该工作流只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作。真实官方网络全量拉取 smoke 已固化,真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`
当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`;跨 release 的 Translation Memory V1 位于 `<output>/translation-memory.sqlite`,不放在 `versions/<id>` 或 release task 库中;
当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`;跨 release 的 Translation Memory SQLite persistence schema V2 位于 `<output>/translation-memory.sqlite`,不放在 `versions/<id>` 或 release task 库中;
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
job/unit/provider run 状态;`translation.memory.summary/query/confirm` 提供
Rust-owned TM 的摘要、source/context 查询显式 trusted 确认,`bat-api` 仅作
typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取
job/unit/provider run 状态;`translation.memory.summary/query/confirm/conflicts/resolve_conflict`
提供 Rust-owned TM 的摘要、source/context 查询显式 trusted 确认和冲突治理
`bat-api` 仅作 typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取
任务,最终资源报告按 pull plan 顺序输出。
项目级 Glossary V1 位于 `<output>/glossary.sqlite`,独立于 release task 和 TM
项目级 Glossary 使用 Glossary domain/feature contract V1,由 SQLite persistence schema V2 承载,位于 `<output>/glossary.sqlite`,独立于 release task 和 TM
Rust `bat` 持有 term/alias/recommended/allowed/category/priority、全局或
TextUnit scope、source history 和 approved review。worker、TM 复用、人工 task
结果和 workbench publish 都执行确定性 QAblocking deviation 必须携带
reviewer/reason/provenance 的显式 override`translation.glossary.*` 已通过
稳定 `qa_identity` 以及 reviewer/reason/provenance 的显式 override,所有接受路径都会
按当前 QA 精确校验 identity。`translation.glossary.*` 已通过
`bat.sock` 暴露,Go `bat-api` 仅做鉴权 typed forwarding。
三个长期 SQLite owner 现在统一使用只读 schema preflight、精确 component
fingerprint 和 `BEGIN IMMEDIATE` writer transactionTranslation Tasks 从 V1
按显式 `v1 -> v2` step 迁移,当前版本为 V2Translation Memory persistence schema
当前版本为 V2Glossary domain/feature contract 为 V1、persistence schema 为 V2。
Translation Memory V2 正式建立
current Trusted 唯一性、显式 supersede、冲突只读诊断、resolve_conflict 和 audit event
Glossary persistence schema V2 正式吸收历史上未升版本的
`glossary_term_deletions` schema drift:原始 V1-A 会在事务内创建 deletion audit
表,带 deletion audit 的 V1-B 只提升 bookkeeping version。future、未知或版本与结构
不一致的数据库在任何 schema/data mutation 前 fail closedmigration 失败会 rollback
已知无 `schema_migrations` 表的历史 fingerprint 可安全补建版本表后重试。正式 schema
路径不再使用 `ensure_column` 隐式补列。
`localized.status` 现将 generic manifest schema/contract 与已发布 artifact integrity
分开报告;current、state 和 identity 存在但文件被截断或手工修改时返回
`localized.degraded`,只读检查不会自动回滚、删除或修复。双 release 的
`release.attestation` 已由 Rust 从 current、canonical versioned resource root、publication
anchor、manifest identity 和 verification generation/freshness 生成,供 Go current
readiness 使用;`resource.manifest` 请求必须绑定同一 generation`release.status/list/distribution/cleanup`
仍由 Rust 从既有状态、manifest、文件系统和 CAS/reference 元数据统一生成,Go 仅 typed 转发。
CAS repository 的对象文件、引用计数
和 GC 通过跨进程操作锁协调,release-local CAS 引用以 `(ownership_id, ordinal)` ownership
记录幂等释放;新清单持久化 `ownership_id`,旧清单按 output-root scope、稳定 source
identity 和 generation-aware legacy cleanup path 迁移;localized publish/rollback 通过
output-root 单写者锁和事务日志恢复
current、version-state、version 目录,publish 只有最终 `verified` phase 才能恢复为已提交。
localized release 还写入实际 bytes/BLAKE3、完整 source mapping identity 和 destination
index 的 `localized-distribution-manifest.json`official download manifest 同样持久化
canonical mapping identity 和 destination index。新 official release 在完整发布验证后
额外写入独立的 `official-distribution-publication.json`,关联 release ID、mapping
identity、manifest content identity 和 entry count。`release.distribution` 只选择
publication identity 与当前 official manifest 一致的 release`destination=...`
比较 persisted identity、查目标索引并校验单文件,返回 exactly one entry 且不在分发热路径
执行完整 release audit;缺少 publication metadata 的 legacy release 仍可读和清理,但不
满足 distribution-ready。`release.cleanup execute` 与 official sync 共用同一个
`.official-sync.lock`localized cleanup 继续使用 `.localized-release.lock`。legacy CAS
manifest 首次 cleanup 时按 output-root scope、release generation 和稳定 source identity
迁移;已有 basename ledger 的部分 cleanup 只有在 output-root 持久化的显式 compatibility
marker 存在时才保持兼容 key,无法证明归属时拒绝 cleanup;完成后同名新 generation 不再复用。
---
## 2. 权威文档入口
@@ -112,7 +153,7 @@ reviewer/reason/provenance 的显式 override。`translation.glossary.*` 已通
待完成:
- `crates/bat-assetbundle` 已具备 UnityFS 容器、对象表、TypeTree 元数据、基础字段读取、TextAsset 和 TextUnit 提取;UnityFS 容器已补充总大小、计数、路径、重复 directory、LZMA 和边界校验,并通过 UnityPy 真实 bundle 隔离回归;已有 TextAsset、TypeTree string fieldmanaged-reference string field 的 localized patch 发布闭环,真实复杂版本差异、整体 AssetBundle 重打包和通用 Patch 仍未实现。
- `crates/bat-assetbundle` 已具备 UnityFS 容器、对象表、TypeTree 元数据、基础字段读取、TextAsset 和 TextUnit 提取;UnityFS 容器已补充总大小、计数、路径、重复 directory、LZMA 和边界校验,并通过 UnityPy 真实 bundle 隔离回归。对当前真实/合成回归覆盖的结构,TextAsset、TypeTree string fieldmanaged-reference string field 和语义字段已形成 parse→modify→rebuild→reparse 闭环,重建保留已识别压缩/对齐/目录形态并校验未修改对象/字段;整体任意 AssetBundle、未知结构和全部真实版本差异仍未实现。
- Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
@@ -151,17 +192,20 @@ reviewer/reason/provenance 的显式 override。`translation.glossary.*` 已通
- `OfficialResourcePullService`:官方 URL 拒绝策略、目标路径映射、下载 manifest、下载 quarantine、`.part` 续传、curl 代理配置、403/404/5xx 分类重试、ZIP 结构校验、官方 seed `.hash` 校验、本地全量 verify。
- `OfficialUpdateService`:官方 metadata auto-discover、bootstrap cache、snapshot diff、marker diff、本地 audit/repair、失败 staging 恢复。
- `bat`:正式 CLI binary,支持 one-shot、`--proxy` / `--no-proxy``--watch``--daemon``status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`
- `release_ops.rs`:从既有 official/localized state、manifest、filesystem 和 CAS reference
元数据生成双 release `status/list/distribution/cleanup`;默认 official 分发,localized
和历史 release 仅在 Rust 完整性验证通过后可选,cleanup 使用 dry-run `plan_id` 和执行前重验证。
- `report_output.rs``terminal_output.rs`:分别负责结果报告渲染和前台终端诊断、帮助、进度及结构化日志输出。
待完成:
- 基于已接入的 `translation.worker.run` 继续推进完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit/TM/Glossary 查询和通用 Patch 发布资源视图
- 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary 和复杂 AssetBundle fixturegeneric manifest V1 与双 release 运维 V1 已完成
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`;实际运行报告由脚本写入隔离输出目录。
- 增加更多权限和极端文件系统场景测试。
### `bat-assetbundle`
状态:**UnityFS 解包、TypeTree 字段读取、TextUnit 提取和受支持 localized patch 发布可用;复杂结构覆盖与整体 AssetBundle 重打包仍待继续补齐**
状态:**已验证 UnityFS 结构的解析、变长修改、重建、重解析和受支持 localized 发布可用;任意复杂结构兼容仍待继续补齐**
解析扩展当前按路线图和真实 fixture 验收推进。
@@ -180,20 +224,20 @@ reviewer/reason/provenance 的显式 override。`translation.glossary.*` 已通
待完成:
- 真实 MonoBehaviour、ScriptableObject 版本差异、复杂容器结构调整、unknown 字段结构语义和未见样本驱动的完整 managed reference registry / map entry 变体覆盖;TypeTree-covered managed reference 字段与 registry 记录已可结构化解码,常见 full typename 可拆解为 assembly/namespace/class,不做低保真猜测。
- 复杂对象整体结构修改后的发布级 AssetBundle 重打包;UnityFS TextAsset、TypeTree string 字段、managed-reference registry payload 字符串、基础语义字段、enum、bit_field、object 字段组合和 TypeTree schema 支撑的 array/vector/map 整体替换的文件级链路已具备重建后校验受支持 localized patch 已有独立 stagingmanifestcurrent、状态校验和显式 rollback;整体 AssetBundle 发布仍未完成
- 任意复杂对象整体结构和所有真实版本差异的发布级 AssetBundle 重打包;当前已验证的 UnityFS TextAsset、TypeTree string 字段、managed-reference registry payload 字符串、基础语义字段、enum、bit_field、object 字段组合和 TypeTree schema 支撑的 array/vector/map 整体替换已具备变长重建、压缩/对齐保留、未修改对象/字段校验受支持 localized staging/manifest/current/rollback;带 `archive_entry` 的可验证 ZIP 内 bundle 也会重写外层 ZIP
- 真实资源 fixture 覆盖对象级解析和文本提取。
- 详细补全顺序见 `docs/architecture/assetbundle.md`
### `bat-patch`
状态:**通用 Binary/JSON/Text Patch 基础可用;受支持 localized patch 发布/rollback 已完成,通用 Patch 发布仍未完成**
状态:**通用 manifest 驱动的受支持 Patch 发布/rollback 已完成;复杂 AssetBundle 兼容仍未完成**
当前已有确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据。`patch.apply` RPC 与 `patch-apply` CLI 可对显式 source/patch/target 文件执行 Binary/JSON/Text patch,并返回 size/BLAKE3 报告;`unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` RPC 和 `unityfs-patch-text-asset` / `unityfs-patch-string-field` / `unityfs-patch-field` CLI 可对显式 UnityFS bundle 输出目标文件。`unityfs.patch_field` 支持 bool、signed/unsigned integer、float raw bits、string、bytes、enum、bit_field、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 同长度替换、PPtr、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换语义 JSON 值;array/vector/List/HashSet/map 扩容会复用当前首个元素或 TypeTree data node 的编码 schema,空容器扩容已用合成 fixture 覆盖,嵌套 vector `Array``List<T>``HashSet<T>` 形态、enum、bit_field、unknown fixed-size raw bytes、managed-reference registry `data``managedReferenceData` payload 字符串已有重建后重解析 fixture。`bat-assetbundle` + `LocalizedPatchService` 已能对 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 执行替换,写入带 TextUnit/provider/review/rollback trace 的 localized patch manifest,在独立 staging 校验后发布汉化 release,并通过 `localized.publish` / `localized.rollback` RPC、`i18n publish` / `i18n rollback` CLI 和 bat-api 控制面暴露`LocalizedPatchManifest` 可转换为通用 `bat_patch::PatchManifest`,通用 manifest 驱动发布仍未迁移
当前已有确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、manifest builder、BLAKE3/size 完整性校验和 rollback 元数据。`patch.apply` RPC 与 `patch-apply` CLI 可对显式 source/patch/target 文件执行 Binary/JSON/Text patch,并返回 size/BLAKE3 报告;`unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` RPC 和对应 CLI 可对显式 UnityFS bundle 输出目标文件。`bat-assetbundle` + `LocalizedPatchService` 已把 Binary/JSON/Text 与当前支持的 UnityFS TextAsset、TypeTree string/semantic field 操作统一到有序 generic manifest,在独立 staging 中逐操作校验 source precondition、最终 hash/size 和 ZIP 内层重解析结果,发布后保留 TextUnit/provider/TM/Glossary/review/rollback provenance,并通过 `localized.publish` / `localized.rollback` RPC、`i18n publish` / `i18n rollback` CLI 和 bat-api 控制面暴露。
待完成:
- 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包。
- 通用 manifest 驱动的跨类型 patch build/apply/diff 发布;当前 localized 发布仅接受已验证 TextUnit 对应的受支持 UnityFS 文本字段,并不等价于整体 AssetBundle 重打包。
- 未见样本驱动的复杂 AssetBundle 重打包;当前 generic manifest 和双 release 运维 V1 只承诺已验证的 Binary/JSON/TextUnityFS 结构及 Rust-owned release 查询/分发/安全清理,不等价于任意整体 AssetBundle 重打包。
- `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。
### `bat-ffi`
@@ -227,14 +271,16 @@ reviewer/reason/provenance 的显式 override。`translation.glossary.*` 已通
| 角色 | 所有者 | 状态 |
|---|---|---|
| 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 |
| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + CDN MVP + RPC 周期刷新/诊断 + readiness + 内嵌 dashboard |
| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + official/localized/historical verified CDN MVP + RPC 周期刷新/诊断 + readiness + release 管理转发 + 内嵌 dashboard |
| daemon RPC client | `internal/backendrpc` | 完成 |
| 试验 CLI | `cmd/bat``bin/bat-go` | 非产品 |
| FFI | `internal/ffi` | 可选 |
| 空目录 `api/` `pkg/` 等 | 占位 | 无实现 |
| Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 |
默认 Go/docs 门禁:`make test-go-api``make build-go-api``make check-docs`(无 FFI)。
默认 Go/docs 只读门禁:`make ci-check`Rust fmt/check/build/clippy/test、Go API
format/test/vet/build、固定版本 `golangci-lint 2.12.2`、docs/OpenAPI/RPC contract
无 FFI)。`make format` / `make fmt` 才会修改源码;required 工具缺失或版本不匹配直接失败。
---
@@ -278,7 +324,7 @@ cargo run -p bat-infrastructure --bin bat -- \
--watch
```
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status``resource.manifest``official-sync-snapshot.json` 和 Glossary query contract fixtureGo mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh``schedule.*``task.*` 查询/取消、`daemon.logs``parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``translation.glossary.*``localized.publish``localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取 Rust 当前 official `release.attestation`,再按 release/publication/mapping/manifest identity 和 verification generation 绑定读取 `resource.manifest`,不在配置里写死资源目录;轻量 attestation 只读取 current、canonical versioned root、publication anchor、manifest 元数据和 freshness,不遍历历史 release 或计算资源文件 BLAKE3。Rust watch 周期负责 current 本地 manifest 验证并更新 attestation,默认 freshness window 为 `2 * 3600 + 60 = 7260` 秒;HTTP readiness 还要求 Go 分页快照完整且本地路径安全;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status``resource.manifest``official-sync-snapshot.json` 和 Glossary query contract fixtureGo mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh``schedule.*``task.*` 查询/取消、`daemon.logs``parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm/conflicts/resolve_conflict``translation.glossary.*``localized.publish``localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
生产要求:
@@ -304,7 +350,7 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
后续工程顺序:
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
2. 继续通用 Patchmanifest 驱动、双 release 查询和清理策略
2. 继续通用 Patch真实样本驱动的复杂 AssetBundle 兼容;双 release 查询、分发、rollback 边界和安全清理 V1 已完成
3. 继续资源查询和翻译基础设施:更丰富的查询和 Provider
扩展体系。
4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
@@ -315,7 +361,6 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
- **当前基线状态**Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP、
HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard
`backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、
通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用;
完整 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和通用 manifest 发布未完成。
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、通用 manifest Patch
构建,以及真实官方资源长期运行验证。
通用 Binary/JSON/Text Patch 基础、generic manifest 和受支持 localized patch 发布/rollback 可用;
复杂 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和更高阶 release retention 未完成;双 release 运维 V1 已完成。
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包,以及真实官方资源长期运行验证。
Generated
+2
View File
@@ -103,6 +103,7 @@ dependencies = [
"async-trait",
"blake3",
"hex",
"libc",
"serde",
"serde_json",
"sqlx",
@@ -118,6 +119,7 @@ version = "1.0.0"
dependencies = [
"anyhow",
"async-trait",
"blake3",
"serde",
"serde_json",
"tempfile",
+367
View File
@@ -0,0 +1,367 @@
# Design System Inspiration of Linear
## 1. Visual Theme & Atmosphere
Linear's website is a masterclass in dark-mode-first product design — a near-black canvas (`#08090a`) where content emerges from darkness like starlight. The overall impression is one of extreme precision engineering: every element exists in a carefully calibrated hierarchy of luminance, from barely-visible borders (`rgba(255,255,255,0.05)`) to soft, luminous text (`#f7f8f8`). This is not a dark theme applied to a light design — it is darkness as the native medium, where information density is managed through subtle gradations of white opacity rather than color variation.
The typography system is built entirely on Inter Variable with OpenType features `"cv01"` and `"ss03"` enabled globally, giving the typeface a cleaner, more geometric character. Inter is used at a remarkable range of weights — from 300 (light body) through 510 (medium, Linear's signature weight) to 590 (semibold emphasis). The 510 weight is particularly distinctive: it sits between regular and medium, creating a subtle emphasis that doesn't shout. At display sizes (72px, 64px, 48px), Inter uses aggressive negative letter-spacing (-1.584px to -1.056px), creating compressed, authoritative headlines that feel engineered rather than designed. Berkeley Mono serves as the monospace companion for code and technical labels, with fallbacks to ui-monospace, SF Mono, and Menlo.
The color system is almost entirely achromatic — dark backgrounds with white/gray text — punctuated by a single brand accent: Linear's signature indigo-violet (`#5e6ad2` for backgrounds, `#7170ff` for interactive accents). This accent color is used sparingly and intentionally, appearing only on CTAs, active states, and brand elements. The border system uses ultra-thin, semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) that create structure without visual noise, like wireframes drawn in moonlight.
**Key Characteristics:**
- Dark-mode-native: `#08090a` marketing background, `#0f1011` panel background, `#191a1b` elevated surfaces
- Inter Variable with `"cv01", "ss03"` globally — geometric alternates for a cleaner aesthetic
- Signature weight 510 (between regular and medium) for most UI text
- Aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px)
- Brand indigo-violet: `#5e6ad2` (bg) / `#7170ff` (accent) / `#828fff` (hover) — the only chromatic color in the system
- Semi-transparent white borders throughout: `rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`
- Button backgrounds at near-zero opacity: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)`
- Multi-layered shadows with inset variants for depth on dark surfaces
- Radix UI primitives as the component foundation (6 detected primitives)
- Success green (`#27a644`, `#10b981`) used only for status indicators
## 2. Color Palette & Roles
### Background Surfaces
- **Marketing Black** (`#010102` / `#08090a`): The deepest background — the canvas for hero sections and marketing pages. Near-pure black with an imperceptible blue-cool undertone.
- **Panel Dark** (`#0f1011`): Sidebar and panel backgrounds. One step up from the marketing black.
- **Level 3 Surface** (`#191a1b`): Elevated surface areas, card backgrounds, dropdowns.
- **Secondary Surface** (`#28282c`): The lightest dark surface — used for hover states and slightly elevated components.
### Text & Content
- **Primary Text** (`#f7f8f8`): Near-white with a barely-warm cast. The default text color — not pure white, preventing eye strain on dark backgrounds.
- **Secondary Text** (`#d0d6e0`): Cool silver-gray for body text, descriptions, and secondary content.
- **Tertiary Text** (`#8a8f98`): Muted gray for placeholders, metadata, and de-emphasized content.
- **Quaternary Text** (`#62666d`): The most subdued text — timestamps, disabled states, subtle labels.
### Brand & Accent
- **Brand Indigo** (`#5e6ad2`): Primary brand color — used for CTA button backgrounds, brand marks, and key interactive surfaces.
- **Accent Violet** (`#7170ff`): Brighter variant for interactive elements — links, active states, selected items.
- **Accent Hover** (`#828fff`): Lighter, more saturated variant for hover states on accent elements.
- **Security Lavender** (`#7a7fad`): Muted indigo used specifically for security-related UI elements.
### Status Colors
- **Green** (`#27a644`): Primary success/active status. Used for "in progress" indicators.
- **Emerald** (`#10b981`): Secondary success — pill badges, completion states.
### Border & Divider
- **Border Primary** (`#23252a`): Solid dark border for prominent separations.
- **Border Secondary** (`#34343a`): Slightly lighter solid border.
- **Border Tertiary** (`#3e3e44`): Lightest solid border variant.
- **Border Subtle** (`rgba(255,255,255,0.05)`): Ultra-subtle semi-transparent border — the default.
- **Border Standard** (`rgba(255,255,255,0.08)`): Standard semi-transparent border for cards, inputs, code blocks.
- **Line Tint** (`#141516`): Nearly invisible line for the subtlest divisions.
- **Line Tertiary** (`#18191a`): Slightly more visible divider line.
### Light Mode Neutrals (for light theme contexts)
- **Light Background** (`#f7f8f8`): Page background in light mode.
- **Light Surface** (`#f3f4f5` / `#f5f6f7`): Subtle surface tinting.
- **Light Border** (`#d0d6e0`): Visible border in light contexts.
- **Light Border Alt** (`#e6e6e6`): Alternative lighter border.
- **Pure White** (`#ffffff`): Card surfaces, highlights.
### Overlay
- **Overlay Primary** (`rgba(0,0,0,0.85)`): Modal/dialog backdrop — extremely dark for focus isolation.
## 3. Typography Rules
### Font Family
- **Primary**: `Inter Variable`, with fallbacks: `SF Pro Display, -apple-system, system-ui, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue`
- **Monospace**: `Berkeley Mono`, with fallbacks: `ui-monospace, SF Mono, Menlo`
- **OpenType Features**: `"cv01", "ss03"` enabled globally — cv01 provides an alternate lowercase 'a' (single-story), ss03 adjusts specific letterforms for a cleaner geometric appearance.
### Hierarchy
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|------|--------|-------------|----------------|-------|
| Display XL | Inter Variable | 72px (4.50rem) | 510 | 1.00 (tight) | -1.584px | Hero headlines, maximum impact |
| Display Large | Inter Variable | 64px (4.00rem) | 510 | 1.00 (tight) | -1.408px | Secondary hero text |
| Display | Inter Variable | 48px (3.00rem) | 510 | 1.00 (tight) | -1.056px | Section headlines |
| Heading 1 | Inter Variable | 32px (2.00rem) | 400 | 1.13 (tight) | -0.704px | Major section titles |
| Heading 2 | Inter Variable | 24px (1.50rem) | 400 | 1.33 | -0.288px | Sub-section headings |
| Heading 3 | Inter Variable | 20px (1.25rem) | 590 | 1.33 | -0.24px | Feature titles, card headers |
| Body Large | Inter Variable | 18px (1.13rem) | 400 | 1.60 (relaxed) | -0.165px | Introduction text, feature descriptions |
| Body Emphasis | Inter Variable | 17px (1.06rem) | 590 | 1.60 (relaxed) | normal | Emphasized body, sub-headings in content |
| Body | Inter Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard reading text |
| Body Medium | Inter Variable | 16px (1.00rem) | 510 | 1.50 | normal | Navigation, labels |
| Body Semibold | Inter Variable | 16px (1.00rem) | 590 | 1.50 | normal | Strong emphasis |
| Small | Inter Variable | 15px (0.94rem) | 400 | 1.60 (relaxed) | -0.165px | Secondary body text |
| Small Medium | Inter Variable | 15px (0.94rem) | 510 | 1.60 (relaxed) | -0.165px | Emphasized small text |
| Small Semibold | Inter Variable | 15px (0.94rem) | 590 | 1.60 (relaxed) | -0.165px | Strong small text |
| Small Light | Inter Variable | 15px (0.94rem) | 300 | 1.47 | -0.165px | De-emphasized body |
| Caption Large | Inter Variable | 14px (0.88rem) | 510590 | 1.50 | -0.182px | Sub-labels, category headers |
| Caption | Inter Variable | 13px (0.81rem) | 400510 | 1.50 | -0.13px | Metadata, timestamps |
| Label | Inter Variable | 12px (0.75rem) | 400590 | 1.40 | normal | Button text, small labels |
| Micro | Inter Variable | 11px (0.69rem) | 510 | 1.40 | normal | Tiny labels |
| Tiny | Inter Variable | 10px (0.63rem) | 400510 | 1.50 | -0.15px | Overline text, sometimes uppercase |
| Link Large | Inter Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard links |
| Link Medium | Inter Variable | 15px (0.94rem) | 510 | 2.67 | normal | Spaced navigation links |
| Link Small | Inter Variable | 14px (0.88rem) | 510 | 1.50 | normal | Compact links |
| Link Caption | Inter Variable | 13px (0.81rem) | 400510 | 1.50 | -0.13px | Footer, metadata links |
| Mono Body | Berkeley Mono | 14px (0.88rem) | 400 | 1.50 | normal | Code blocks |
| Mono Caption | Berkeley Mono | 13px (0.81rem) | 400 | 1.50 | normal | Code labels |
| Mono Label | Berkeley Mono | 12px (0.75rem) | 400 | 1.40 | normal | Code metadata, sometimes uppercase |
### Principles
- **510 is the signature weight**: Linear uses Inter Variable's 510 weight (between regular 400 and medium 500) as its default emphasis weight. This creates a subtly bolded feel without the heaviness of traditional medium or semibold.
- **Compression at scale**: Display sizes use progressively tighter letter-spacing — -1.584px at 72px, -1.408px at 64px, -1.056px at 48px, -0.704px at 32px. Below 24px, spacing relaxes toward normal.
- **OpenType as identity**: `"cv01", "ss03"` aren't decorative — they transform Inter into Linear's distinctive typeface, giving it a more geometric, purposeful character.
- **Three-tier weight system**: 400 (reading), 510 (emphasis/UI), 590 (strong emphasis). The 300 weight appears only in deliberately de-emphasized contexts.
## 4. Component Stylings
### Buttons
**Ghost Button (Default)**
- Background: `rgba(255,255,255,0.02)`
- Text: `#e2e4e7` (near-white)
- Padding: comfortable
- Radius: 6px
- Border: `1px solid rgb(36, 40, 44)`
- Outline: none
- Focus shadow: `rgba(0,0,0,0.1) 0px 4px 12px`
- Use: Standard actions, secondary CTAs
**Subtle Button**
- Background: `rgba(255,255,255,0.04)`
- Text: `#d0d6e0` (silver-gray)
- Padding: 0px 6px
- Radius: 6px
- Use: Toolbar actions, contextual buttons
**Primary Brand Button (Inferred)**
- Background: `#5e6ad2` (brand indigo)
- Text: `#ffffff`
- Padding: 8px 16px
- Radius: 6px
- Hover: `#828fff` shift
- Use: Primary CTAs ("Start building", "Sign up")
**Icon Button (Circle)**
- Background: `rgba(255,255,255,0.03)` or `rgba(255,255,255,0.05)`
- Text: `#f7f8f8` or `#ffffff`
- Radius: 50%
- Border: `1px solid rgba(255,255,255,0.08)`
- Use: Close, menu toggle, icon-only actions
**Pill Button**
- Background: transparent
- Text: `#d0d6e0`
- Padding: 0px 10px 0px 5px
- Radius: 9999px
- Border: `1px solid rgb(35, 37, 42)`
- Use: Filter chips, tags, status indicators
**Small Toolbar Button**
- Background: `rgba(255,255,255,0.05)`
- Text: `#62666d` (muted)
- Radius: 2px
- Border: `1px solid rgba(255,255,255,0.05)`
- Shadow: `rgba(0,0,0,0.03) 0px 1.2px 0px 0px`
- Font: 12px weight 510
- Use: Toolbar actions, quick-access controls
### Cards & Containers
- Background: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)` (never solid — always translucent)
- Border: `1px solid rgba(255,255,255,0.08)` (standard) or `1px solid rgba(255,255,255,0.05)` (subtle)
- Radius: 8px (standard), 12px (featured), 22px (large panels)
- Shadow: `rgba(0,0,0,0.2) 0px 0px 0px 1px` or layered multi-shadow stacks
- Hover: subtle background opacity increase
### Inputs & Forms
**Text Area**
- Background: `rgba(255,255,255,0.02)`
- Text: `#d0d6e0`
- Border: `1px solid rgba(255,255,255,0.08)`
- Padding: 12px 14px
- Radius: 6px
**Search Input**
- Background: transparent
- Text: `#f7f8f8`
- Padding: 1px 32px (icon-aware)
**Button-style Input**
- Text: `#8a8f98`
- Padding: 1px 6px
- Radius: 5px
- Focus shadow: multi-layer stack
### Badges & Pills
**Success Pill**
- Background: `#10b981`
- Text: `#f7f8f8`
- Radius: 50% (circular)
- Font: 10px weight 510
- Use: Status dots, completion indicators
**Neutral Pill**
- Background: transparent
- Text: `#d0d6e0`
- Padding: 0px 10px 0px 5px
- Radius: 9999px
- Border: `1px solid rgb(35, 37, 42)`
- Font: 12px weight 510
- Use: Tags, filter chips, category labels
**Subtle Badge**
- Background: `rgba(255,255,255,0.05)`
- Text: `#f7f8f8`
- Padding: 0px 8px 0px 2px
- Radius: 2px
- Border: `1px solid rgba(255,255,255,0.05)`
- Font: 10px weight 510
- Use: Inline labels, version tags
### Navigation
- Dark sticky header on near-black background
- Linear logomark left-aligned (SVG icon)
- Links: Inter Variable 1314px weight 510, `#d0d6e0` text
- Active/hover: text lightens to `#f7f8f8`
- CTA: Brand indigo button or ghost button
- Mobile: hamburger collapse
- Search: command palette trigger (`/` or `Cmd+K`)
### Image Treatment
- Product screenshots on dark backgrounds with subtle border (`rgba(255,255,255,0.08)`)
- Top-rounded images: `12px 12px 0px 0px` radius
- Dashboard/issue previews dominate feature sections
- Subtle shadow beneath screenshots: `rgba(0,0,0,0.4) 0px 2px 4px`
## 5. Layout Principles
### Spacing System
- Base unit: 8px
- Scale: 1px, 4px, 7px, 8px, 11px, 12px, 16px, 19px, 20px, 22px, 24px, 28px, 32px, 35px
- The 7px and 11px values suggest micro-adjustments for optical alignment
- Primary rhythm: 8px, 16px, 24px, 32px (standard 8px grid)
### Grid & Container
- Max content width: approximately 1200px
- Hero: centered single-column with generous vertical padding
- Feature sections: 23 column grids for feature cards
- Full-width dark sections with internal max-width constraints
- Changelog: single-column timeline layout
### Whitespace Philosophy
- **Darkness as space**: On Linear's dark canvas, empty space isn't white — it's absence. The near-black background IS the whitespace, and content emerges from it.
- **Compressed headlines, expanded surroundings**: Display text at 72px with -1.584px tracking is dense and compressed, but sits within vast dark padding. The contrast between typographic density and spatial generosity creates tension.
- **Section isolation**: Each feature section is separated by generous vertical padding (80px+) with no visible dividers — the dark background provides natural separation.
### Border Radius Scale
- Micro (2px): Inline badges, toolbar buttons, subtle tags
- Standard (4px): Small containers, list items
- Comfortable (6px): Buttons, inputs, functional elements
- Card (8px): Cards, dropdowns, popovers
- Panel (12px): Panels, featured cards, section containers
- Large (22px): Large panel elements
- Full Pill (9999px): Chips, filter pills, status tags
- Circle (50%): Icon buttons, avatars, status dots
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat (Level 0) | No shadow, `#010102` bg | Page background, deepest canvas |
| Subtle (Level 1) | `rgba(0,0,0,0.03) 0px 1.2px 0px` | Toolbar buttons, micro-elevation |
| Surface (Level 2) | `rgba(255,255,255,0.05)` bg + `1px solid rgba(255,255,255,0.08)` border | Cards, input fields, containers |
| Inset (Level 2b) | `rgba(0,0,0,0.2) 0px 0px 12px 0px inset` | Recessed panels, inner shadows |
| Ring (Level 3) | `rgba(0,0,0,0.2) 0px 0px 0px 1px` | Border-as-shadow technique |
| Elevated (Level 4) | `rgba(0,0,0,0.4) 0px 2px 4px` | Floating elements, dropdowns |
| Dialog (Level 5) | Multi-layer stack: `rgba(0,0,0,0) 0px 8px 2px, rgba(0,0,0,0.01) 0px 5px 2px, rgba(0,0,0,0.04) 0px 3px 2px, rgba(0,0,0,0.07) 0px 1px 1px, rgba(0,0,0,0.08) 0px 0px 1px` | Popovers, command palette, modals |
| Focus | `rgba(0,0,0,0.1) 0px 4px 12px` + additional layers | Keyboard focus on interactive elements |
**Shadow Philosophy**: On dark surfaces, traditional shadows (dark on dark) are nearly invisible. Linear solves this by using semi-transparent white borders as the primary depth indicator. Elevation isn't communicated through shadow darkness but through background luminance steps — each level slightly increases the white opacity of the surface background (`0.02``0.04``0.05`), creating a subtle stacking effect. The inset shadow technique (`rgba(0,0,0,0.2) 0px 0px 12px 0px inset`) creates a unique "sunken" effect for recessed panels, adding dimensional depth that traditional dark themes lack.
## 7. Do's and Don'ts
### Do
- Use Inter Variable with `"cv01", "ss03"` on ALL text — these features are fundamental to Linear's typeface identity
- Use weight 510 as your default emphasis weight — it's Linear's signature between-weight
- Apply aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px)
- Build on near-black backgrounds: `#08090a` for marketing, `#0f1011` for panels, `#191a1b` for elevated surfaces
- Use semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) instead of solid dark borders
- Keep button backgrounds nearly transparent: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)`
- Reserve brand indigo (`#5e6ad2` / `#7170ff`) for primary CTAs and interactive accents only
- Use `#f7f8f8` for primary text — not pure `#ffffff`, which would be too harsh
- Apply the luminance stacking model: deeper = darker bg, elevated = slightly lighter bg
### Don't
- Don't use pure white (`#ffffff`) as primary text — `#f7f8f8` prevents eye strain
- Don't use solid colored backgrounds for buttons — transparency is the system (rgba white at 0.020.05)
- Don't apply the brand indigo decoratively — it's reserved for interactive/CTA elements only
- Don't use positive letter-spacing on display text — Inter at large sizes always runs negative
- Don't use visible/opaque borders on dark backgrounds — borders should be whisper-thin semi-transparent white
- Don't skip the OpenType features (`"cv01", "ss03"`) — without them, it's generic Inter, not Linear's Inter
- Don't use weight 700 (bold) — Linear's maximum weight is 590, with 510 as the workhorse
- Don't introduce warm colors into the UI chrome — the palette is cool gray with blue-violet accent only
- Don't use drop shadows for elevation on dark surfaces — use background luminance stepping instead
## 8. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile Small | <600px | Single column, compact padding |
| Mobile | 600640px | Standard mobile layout |
| Tablet | 640768px | Two-column grids begin |
| Desktop Small | 7681024px | Full card grids, expanded padding |
| Desktop | 10241280px | Standard desktop, full navigation |
| Large Desktop | >1280px | Full layout, generous margins |
### Touch Targets
- Buttons use comfortable padding with 6px radius minimum
- Navigation links at 1314px with adequate spacing
- Pill tags have 10px horizontal padding for touch accessibility
- Icon buttons at 50% radius ensure circular, easy-to-tap targets
- Search trigger is prominently placed with generous hit area
### Collapsing Strategy
- Hero: 72px → 48px → 32px display text, tracking adjusts proportionally
- Navigation: horizontal links + CTAs → hamburger menu at 768px
- Feature cards: 3-column → 2-column → single column stacked
- Product screenshots: maintain aspect ratio, may reduce padding
- Changelog: timeline maintains single-column through all sizes
- Footer: multi-column → stacked single column
- Section spacing: 80px+ → 48px on mobile
### Image Behavior
- Dashboard screenshots maintain border treatment at all sizes
- Hero visuals simplify on mobile (fewer floating UI elements)
- Product screenshots use responsive sizing with consistent radius
- Dark background ensures screenshots blend naturally at any viewport
## 9. Agent Prompt Guide
### Quick Color Reference
- Primary CTA: Brand Indigo (`#5e6ad2`)
- Page Background: Marketing Black (`#08090a`)
- Panel Background: Panel Dark (`#0f1011`)
- Surface: Level 3 (`#191a1b`)
- Heading text: Primary White (`#f7f8f8`)
- Body text: Silver Gray (`#d0d6e0`)
- Muted text: Tertiary Gray (`#8a8f98`)
- Subtle text: Quaternary Gray (`#62666d`)
- Accent: Violet (`#7170ff`)
- Accent Hover: Light Violet (`#828fff`)
- Border (default): `rgba(255,255,255,0.08)`
- Border (subtle): `rgba(255,255,255,0.05)`
- Focus ring: Multi-layer shadow stack
### Example Component Prompts
- "Create a hero section on `#08090a` background. Headline at 48px Inter Variable weight 510, line-height 1.00, letter-spacing -1.056px, color `#f7f8f8`, font-feature-settings `'cv01', 'ss03'`. Subtitle at 18px weight 400, line-height 1.60, color `#8a8f98`. Brand CTA button (`#5e6ad2`, 6px radius, 8px 16px padding) and ghost button (`rgba(255,255,255,0.02)` bg, `1px solid rgba(255,255,255,0.08)` border, 6px radius)."
- "Design a card on dark background: `rgba(255,255,255,0.02)` background, `1px solid rgba(255,255,255,0.08)` border, 8px radius. Title at 20px Inter Variable weight 590, letter-spacing -0.24px, color `#f7f8f8`. Body at 15px weight 400, color `#8a8f98`, letter-spacing -0.165px."
- "Build a pill badge: transparent background, `#d0d6e0` text, 9999px radius, 0px 10px padding, `1px solid #23252a` border, 12px Inter Variable weight 510."
- "Create navigation: dark sticky header on `#0f1011`. Inter Variable 13px weight 510 for links, `#d0d6e0` text. Brand indigo CTA `#5e6ad2` right-aligned with 6px radius. Bottom border: `1px solid rgba(255,255,255,0.05)`."
- "Design a command palette: `#191a1b` background, `1px solid rgba(255,255,255,0.08)` border, 12px radius, multi-layer shadow stack. Input at 16px Inter Variable weight 400, `#f7f8f8` text. Results list with 13px weight 510 labels in `#d0d6e0` and 12px metadata in `#62666d`."
### Iteration Guide
1. Always set font-feature-settings `"cv01", "ss03"` on all Inter text — this is non-negotiable for Linear's look
2. Letter-spacing scales with font size: -1.584px at 72px, -1.056px at 48px, -0.704px at 32px, normal below 16px
3. Three weights: 400 (read), 510 (emphasize/navigate), 590 (announce)
4. Surface elevation via background opacity: `rgba(255,255,255, 0.02 → 0.04 → 0.05)` — never solid backgrounds on dark
5. Brand indigo (`#5e6ad2` / `#7170ff`) is the only chromatic color — everything else is grayscale
6. Borders are always semi-transparent white, never solid dark colors on dark backgrounds
7. Berkeley Mono for any code or technical content, Inter Variable for everything else
+29 -2
View File
@@ -1,6 +1,6 @@
# BlueArchive Toolkit 文档分类索引
- **更新时间**2026-09-04
- **更新时间**2026-09-13
- **用途**:按用途、时效性和权威级别定位文档。
- **原则**:目录是物理归档方式,不能单独代表文档权威性;当前源码、测试和下列当前文档优先于历史报告。
@@ -16,6 +16,8 @@
- `CHANGELOG.md`:版本变更记录,不作为当前实现的唯一依据。
- `CLAUDE.md`:旧工具兼容入口,不承载独立规则。
- `AGENTS.md`AI agent 长期协作规则。
- `TODO.md`:具体工程任务、优先级、依赖与完成条件的仓库内任务账本;不作为当前实现事实来源。
- `DESIGN.md`:Dashboard 的主要视觉参考与设计灵感来源;涉及 Dashboard/Web UI/布局/视觉/组件/交互任务时必须先阅读。
## 2. 当前状态、计划与缺口
@@ -25,6 +27,7 @@
- `docs/reports/GO_STATUS.md`Go `bat-api` 边界和组件进度的权威文档。
- `docs/reports/CURRENT_GAPS.md`:当前缺口、影响和推进顺序。
- `PROJECT_PLAN.md`:目标和路线图;其中的计划项不等于已实现。
- `TODO.md`:当前可执行工程任务、优先级、依赖与验收条件;条目状态不高于源码、测试和 current-status 文档。
- `docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md`Rust 输出、Go contract fixture 和联调的当前交接说明。
## 3. 架构、决策与稳定契约
@@ -51,6 +54,15 @@
契约文档涉及字段、状态码、错误码、release layout 或路径语义时,必须与源码测试和 `internal/api/testdata/contract/` 一起复核。
### 3.4 Dashboard 设计参考
- `DESIGN.md`:用户 Dashboard 与运营 Dashboard 的主要视觉参考和设计灵感来源,描述应延续的色彩关系、排版、空间、边框、层级、组件形态和交互气质。它不定义后端事实、权限或业务状态,也不要求复制参考来源的页面结构或品牌内容。
- Dashboard 的稳定产品职责、信息边界和设计执行规则见 `AGENTS.md` 的“Dashboard 开发与设计”。用户 Dashboard 与运营 Dashboard 共享基础视觉语言和组件体系,但拥有不同的信息架构、信息密度和权限边界。
- Dashboard 设计必须以当前真实 API/RPC contract 和数据结构为依据。若所需信息尚无后端 contract,应记录缺口,而不是在前端维护第二份业务状态或伪造指标。
发生冲突时遵循:`AGENTS.md` 与稳定产品/接口契约 > 当前明确任务需求 > `DESIGN.md` > Agent 自身设计偏好。
## 4. 用户、开发与运维指南
这些文件描述如何使用或验证已经存在的能力:
@@ -118,6 +130,8 @@
## 8. 推荐阅读顺序
### 8.1 项目与开发者通用阅读顺序
1. `README.md`
2. `CURRENT_STATUS.md`
3. `PROJECT_PLAN.md`
@@ -133,4 +147,17 @@
13. `CONTRIBUTING.md`
14. `AGENTS.md`
阅读顺序中的状态和契约结论必须回到当前源码、测试和实际命令验证;历史报告只用于解释演进过程。
### 8.2 AI / Agent 开发接管顺序
Agent 进入仓库进行开发时优先按以下顺序建立上下文:
1. `AGENTS.md`:先确定长期规则、状态所有权和开发边界;
2. `DOCS_INDEX.md`:确认当前任务应阅读的权威文档;
3. `CURRENT_STATUS.md` 与对应专项状态文档:确认当前已经实现的事实;
4. `TODO.md`:确认当前具体任务、优先级、依赖和完成条件;
5. 当前任务直接相关的源码、tests、稳定 contract 和架构文档;
6. `docs/reports/CURRENT_GAPS.md` / `PROJECT_PLAN.md`:需要判断能力缺口或后续路线时再读取。
涉及 Dashboard、Web UI、页面布局、视觉样式、组件或交互体验时,在设计或修改前额外必须阅读 `DESIGN.md`
阅读顺序中的状态和契约结论必须回到当前源码、测试和实际命令验证;`TODO.md``CURRENT_GAPS.md``PROJECT_PLAN.md` 均不能把计划项提升为已实现事实;历史报告只用于解释演进过程。
+23 -10
View File
@@ -1,4 +1,4 @@
.PHONY: help build build-ffi test clean check check-docs fmt lint install dev docker-build docker-up docker-down official-smoke bat-api-local-live-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
.PHONY: help build build-ffi test clean check check-docs check-go-format format fmt lint ci ci-check install dev docker-build docker-up docker-down official-smoke bat-api-local-live-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
# 默认目标
.DEFAULT_GOAL := help
@@ -96,12 +96,18 @@ check-go: ## 检查 Go 代码
@echo "$(BLUE)Checking Go code...$(NC)"
go vet ./...
check-go-format: ## 检查 Go 格式(只读)
@echo "$(BLUE)Checking Go formatting...$(NC)"
bash scripts/check-go-format.sh
check-docs: ## 检查权威状态文档与占位目录声明
@echo "$(BLUE)Checking documentation status claims...$(NC)"
bash scripts/check-doc-status.sh
fmt: fmt-rust fmt-go ## 格式化所有代码
format: fmt ## 格式化所有代码(会修改工作树)
fmt-rust: ## 格式化 Rust 代码
@echo "$(BLUE)Formatting Rust code...$(NC)"
cargo fmt --all
@@ -116,14 +122,19 @@ lint-rust: ## Rust Clippy 检查
@echo "$(BLUE)Running Clippy...$(NC)"
cargo clippy --workspace --all-targets -- -D warnings
lint-go: ## Go Linter 检查
lint-go: ## Go Linter 检查required
@echo "$(BLUE)Running golangci-lint...$(NC)"
@command -v golangci-lint >/dev/null 2>&1 || { echo "$(YELLOW)golangci-lint not installed, skipping...$(NC)"; exit 0; }
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
golangci-lint run ./...; \
else \
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
fi
@. scripts/ci-versions.sh; \
command -v golangci-lint >/dev/null 2>&1 || { \
echo "$(YELLOW)required gate failed: golangci-lint $${GOLANGCI_LINT_VERSION} is not installed$(NC)"; \
exit 1; \
}; \
actual="$$(golangci_lint_actual_version)"; \
test "$${actual}" = "$${GOLANGCI_LINT_VERSION}" || { \
echo "$(YELLOW)required gate failed: golangci-lint version required=$${GOLANGCI_LINT_VERSION} actual=$${actual:-unknown}$(NC)"; \
exit 1; \
}; \
XDG_CACHE_HOME="$${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" golangci-lint run ./...
# ============================================================================
# 清理
@@ -189,5 +200,7 @@ docs: ## 生成文档
# CI/CD
# ============================================================================
ci: fmt lint test ## 运行 CI 检查(本地模拟
@echo "$(GREEN)✓ All CI checks passed!$(NC)"
ci-check: ## 运行只读 required CI 门禁(含固定版本 Go lint
@bash scripts/ci-check.sh
ci: ci-check ## 运行只读 CI 检查(兼容旧命令名)
+17 -16
View File
@@ -32,7 +32,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。
5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。
6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat`
7. `bat` 支持 `--auto-discover``--watch``--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/restart/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``translation.*``localized.status``catalog.*``task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
7. `bat` 支持 `--auto-discover``--watch``--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/restart/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``translation.*``localized.status``release.status/list/distribution/cleanup``catalog.*``task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;`translation.proofread` 可把汉化 workflow 标记为人工校对中,但不会覆盖已发布的汉化 release。
10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json``crowdin-translation-handoff.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。
@@ -41,12 +41,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
### 仍是骨架或占位
1. `bat-assetbundle` 已具备 UnityFS 解包TextAsset 提取基础能力(header/block info/directory、LZ4/LZMA block info 与数据 block、directory 文件提取、serialized file object table、TypeTree node 元数据、TextAsset bytes、TypeTree-covered managed reference payload TextUnit 上下文),并已有受支持 localized patch 发布能力;MonoBehaviour/ScriptableObject 复杂字段级解析、整体重打包和通用 Patch 仍未完成。
2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService`完成受支持 TextUnit 到 localized patch manifest、独立 staging、发布和 rollback 闭环,通用 manifest 发布与整体 AssetBundle 重打包仍后置。
1. `bat-assetbundle` 已具备 UnityFS 解包TextAsset/TypeTree 字段读取和 TextUnit 提取;对当前真实/合成回归覆盖的结构,UnityFS TextAsset、TypeTree string、managed-reference string 和语义字段已形成 parse→modify→rebuild→reparse 闭环,保留已识别压缩/对齐/目录形态并校验未修改对象/字段;所有真实版本差异、未知字段语义和任意复杂 AssetBundle 兼容仍未完成。
2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest/builder、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService`用同一 generic manifest 完成受支持 Binary/JSON/Text/UnityFS 操作的独立 staging、发布和 rollback 闭环;ZIP 内 bundle 在 `archive_entry` 可验证时会重写外层 ZIP,并在最终发布校验中重新解析和核对实际字段值,任意 AssetBundle 重打包仍后置。
3. Go 侧边界已确定(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发和内嵌 dashboard = `cmd/bat-api` MVP`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / 完整 Web 协作后台 / SDK 仍未完成。
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract`translation.worker.run` 已提供真实 provider worker 触发、lease/retry 和结果落库 contract`translation.proofread` 已提供汉化 workflow 人工校对标记 contract`translation.memory.*` 已提供 Rust-owned TM 摘要、raw source/context 查询、provenance 和显式 confirm contractGo 侧仅代理。
6. 受支持汉化 Patch 发布已具备 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 manifest/apply/rollback/完整性校验和 `localized.status` 严格校验;真实 provider worker 与项目级 Translation Memory V1 已接入,翻译记忆到完整汉化文件集合的构建仍未完成。
6. 受支持汉化 Patch 发布已具备 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 manifest/apply/rollback/完整性校验和 `localized.status` 严格校验;ZIP 内 bundle 在 `archive_entry` 可验证时会重写外层 ZIP。真实 provider worker 与项目级 Translation Memory persistence schema V2 已接入,翻译记忆到完整汉化文件集合的构建仍未完成。
7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook;真实运行记录处于长期运行测试阶段,报告待后续提供。
8. 内嵌 dashboard MVP 已实现;完整 Web 协作后台、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发/OpenAPI/管理控制面已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。
9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。
@@ -169,7 +169,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
**目标**:能够获取、解析和同步 Blue Archive 资源清单。
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;`bat-api` 资源 bootstrap/分发入口已落地,CAS + ResourceRepository 导入、历史 release/CAS 复用和 Translation Memory V1 已可用,但完整解析覆盖、丰富查询扩展和真实线上 smoke 仍待完成。
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;`bat-api` 资源 bootstrap/分发入口已落地,CAS + ResourceRepository 导入、历史 release/CAS 复用和 Translation Memory persistence schema V2 已可用,但完整解析覆盖、丰富查询扩展和真实线上 smoke 仍待完成。
交付物:
@@ -213,7 +213,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
1. **解析缓存闭环**:官方同步发布后生成 `official-parse-cache.json`,覆盖 manifest 全部条目、直接 bundle、zip 内 bundle、非候选资源和解析失败诊断;未变化文件按 URL、相对路径、size 和 BLAKE3 复用解析结果。
2. **Addressables 完整化**:覆盖 Windows/Android JSON、compact JSON 和后续二进制 catalog 入口,解析 provider、internal id、primary key、dependency、bundle name、hash、size、CRC 和资源类型。
3. **UnityFS 容器层**:基础目标已完成 header、block info、directory、data block、LZ4/LZMA、alignment、总大小/计数/路径/边界错误、directory 文件提取和 UnityPy 真实样本回归;复杂版本差异和发布级重打包另行推进。
3. **UnityFS 容器层**:基础目标已完成 header、block info、directory、data block、LZ4/LZMA、alignment、总大小/计数/路径/边界错误、directory 文件提取和 UnityPy 真实样本回归;当前已验证结构另有压缩/对齐保留的变长发布级重建闭环,复杂版本差异和任意结构重打包另行推进。
4. **Serialized file 层**:稳定 Unity serialized file header、type table、TypeTree node、object table、path id、class id 和 raw object bytes 表示。
5. **字段级解析层**:实现 TypeTree 字段 reader,支持 bool、integer、float、string、bytes、array、vector/staticvector 嵌套 `Array``List<T>` / `HashSet<T>` 集合 alias、map、PPtr、enum `value__` backing field、`LayerMask` / `BitField``m_Bits` backing field、常见固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 保留和同长度替换、TypeTree-covered managed reference / `SerializedReference` alias 和 TypeTree-covered managed reference registry 记录;managed-reference full typename 可拆为 assembly/namespace/class,常见 `m_ManagedReferences` / `RefIds` / verbose type 字段命名、`managedReference*` / `serializedReference*` metadata 和 `data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData` payload 命名已有回归覆盖,TextUnit 只提取 payload 字符串并按结构化 record、`RefIds[n]` 等记录前缀或子字段保留类型上下文;array/vector/List/HashSet/map 元素与 registry payload 字段保留独立 field path、offset 和 byte size,可支撑字符串元素、managed-reference registry payload 字段、基础语义字段 patch、enum/bit_field 语义 patch、固定值类型 patch、unknown fixed-size bytes patch、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体变长替换,`first/second``key/value` map entry schema 已有回归覆盖;解析模块当前仍有真实版本差异、未见样本驱动的完整 managed reference registry / map entry 变体和 unknown 字段结构语义需要继续推进。
6. **文本对象入口**:实现 TextAsset、MonoBehaviour、ScriptableObject 的可扩展提取入口,输出可追溯到 bundle、serialized file、path id 和 field path 的文本定位。
@@ -260,10 +260,10 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
交付物:
1. Translation Memory V1 已使用项目级 SQLite schemasource raw/hash、translation、完整 context、candidate/trustedprovenance。
2. 已实现 raw source + 完整 context exact match;模糊匹配和完整导入导出仍待实现。Glossary V1 已作为独立项目级 SQLite 资产接入 approved review、scope/alias/priority、provider constraints、确定性 QA 和显式 override。
3. 已实现显式 per-record confirmGlossary V1 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。
4. 已实现 `bat i18n memory summary|query|confirm` 与对应 Rust RPC。
1. Translation Memory persistence schema V2 已使用项目级 SQLite schemasource raw/hash、translation、完整 context、candidate/trustedprovenance、supersede 关系和 audit event
2. 已实现 raw source + 完整 context exact match、current Trusted 唯一性和冲突诊断;模糊匹配和完整导入导出仍待实现。Glossary domain/feature contract V1 已由 SQLite persistence schema V2 承载,接入 approved review、scope/alias/priority、provider constraints、确定性 QA 和显式 override。
3. 已实现显式 per-record confirm、supersede 和历史冲突 resolve;Glossary 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。
4. 已实现 `bat i18n memory summary|query|confirm|conflicts|resolve-conflict` 与对应 Rust RPC。
验收标准:
@@ -306,7 +306,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
3. 实现客户端发现、路径校验、备份、应用、回滚。
4. 实现 `patch build``patch apply``patch rollback``verify`
5. 实现 dry-run 和安全检查。
6. 将通用 Patch manifest 与汉化发布流程进一步统一
6. generic Patch manifest V1 已统一当前支持类型;双 release 的查询、分发选择和安全 cleanup V1 已由 Rust `bat` 持有,复杂 AssetBundle 兼容继续由真实样本驱动
7. `release.status` / `release.list` 提供 official/localized current 与历史 release 统一视图;`release.distribution` 只选择已验证资源,默认 official;`release.cleanup` 采用 dry-run `plan_id`、执行前重验证和 CAS/reference 保护,rollback 保持独立。
验收标准:
@@ -369,7 +370,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
交付物:
1. 发布验证:format、lint、test、build、security audit、release artifact 由本地可重复命令、自托管 Gitea linux-runner workflow 与脚本承担;当前不引入托管 CI
1. 发布验证:format、lint、test、build、security audit、release artifact 由本地可重复命令和脚本承担;项目以本地 `make ci-check` 作为唯一完整 required quality gate,当前不依赖 Gitea、GitHub Actions 或其它远端 CI runner
2. Docker Compose:本地开发、服务端部署。
3. 数据备份与恢复文档。
4. 用户文档、开发文档、故障排查文档。
@@ -387,7 +388,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 5. 推荐执行顺序
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证。
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、复杂 AssetBundle 重打包和真实官方资源长期运行验证。
建议顺序:
@@ -405,7 +406,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
`docs/reports/GO_STATUS.md`
1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力。
2. 基于 `translation.worker.run` 继续推进 Patch 构建。
2. 基于 `translation.worker.run` 继续补充复杂 AssetBundle 的 Patch 构建与发布验证
3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告。
@@ -461,9 +462,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory persistence schema V2、Glossary domain/feature contract V1SQLite persistence schema V2)、通用 Binary/JSON/Text Patch 基础、generic manifest V1、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback、Rust-owned 双 release 查询/分发/cleanup V1,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、真实版本与复杂 AssetBundle 兼容和官方资源长期运行报告。
---
- **下一份应补充的验证材料**:真实官方网络 smoke 运行记录
- **下一项工程任务**:推进 TM/Glossary 扩展、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
- **下一项工程任务**:推进 TM/Glossary 扩展、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
+9 -12
View File
@@ -2,7 +2,7 @@
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、CDN path 只读分发和内嵌管理 dashboard);`internal/backendrpc` 为 RPC client`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、完整 Web 协作后台复杂 AssetBundle 重打包和通用 Patch 发布仍在后续阶段。
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、CDN path 只读分发和内嵌管理 dashboard);`internal/backendrpc` 为 RPC client`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、完整 Web 协作后台复杂 AssetBundle 重打包仍在后续阶段。
---
@@ -20,7 +20,7 @@
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询索引,常用 metadata 过滤会下推到 SQLite;历史 release 复用会重新校验 size、BLAKE3 和 ZIP 结构,失败时按历史 release、CAS、网络顺序回退,CAS 引用记录在 `official-cas-reuse-references.json` 中;`bat doctor cas` 可只读诊断既有 CAS 目录、对象数、对象字节数和元数据库文件状态。
- 新 release 发布后会生成 `official-resource-changes.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-translation-handoff.json``crowdin-textunit-queue.json``translation-tasks.sqlite``translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接。
- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库、Translation Memory V1 和 Glossary V1Glossary 独立于 release task/TM,支持全局与 TextUnit scope、alias、priority、approved review、冲突诊断、provider constraints 和确定性 QA。TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。模糊匹配和完整 Provider 扩展体系仍待实现。
- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库、Translation Memory persistence schema V2 和 Glossary domain/feature contract V1SQLite persistence schema V2Glossary 独立于 release task/TM,支持全局与 TextUnit scope、alias、priority、approved review、冲突诊断、provider constraints、deletion audit 和确定性 QA。TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm、supersede、冲突诊断/解决和 provenance 查询。模糊匹配和完整 Provider 扩展体系仍待实现。
- `LocalizedPatchService` 已具备受支持的 UnityFS localized patch 发布/回滚能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset、TypeTree string field 或 managed-reference string field patch、写入带 TextUnit/provider/review/rollback trace 的 `localized-patch-manifest.json`,校验后发布到 `versions/<id>` 并切换 `current`,也可显式 rollback。
- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,受支持 localized 发布通过独立 manifest/staging/current 流程完成。
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
@@ -30,8 +30,8 @@
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段
- Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory V1 与 Glossary V1 已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前 generic manifest 已驱动已验证的 Binary/JSON/Text 与 UnityFS localized 操作,未知结构仍明确拒绝
- Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory persistence schema V2 与 Glossary domain/feature contract V1SQLite persistence schema V2已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。
- SDK、完整 Web 协作后台。
详细状态见:
@@ -60,16 +60,13 @@
运行当前通用验证:
```bash
cargo fmt --all -- --check
cargo check --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
make test-go-api
make build-go-api
go vet ./...
make check-docs
make ci-check
```
`make ci-check` 是只读 required 门禁;`make format` / `make fmt` 才会格式化源码。
Go lint 是 required gate,使用 `scripts/ci-versions.sh` 固定的
`golangci-lint 2.12.2`;工具缺失或版本不匹配都会失败。
查看官方同步命令:
```bash
+1159
View File
File diff suppressed because it is too large Load Diff
+56 -17
View File
@@ -59,7 +59,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
| `i18n status` | 显示当前汉化 release 状态 |
| `i18n task update` | 回写 provider worker 任务状态 |
| `i18n worker run` | 运行真实 provider worker;支持单次、限定次数和周期执行 |
| `i18n publish` | 校验工作台发布独立汉化 release`--force` 使用新的手动 release ID |
| `i18n publish` | 工作台`--patch-manifest` 发布独立汉化 release`--force` 使用新的手动 release ID |
| `i18n schedule` | 管理翻译和汉化发布计划 |
| `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 |
| `verify` | 校验远端计划、本地 manifest 和官方 seed hashdry-run + 审计当前 release |
@@ -154,7 +154,7 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
| `GET /admin/translation/memory/query?source_text=...&source_context=...&limit=100` | 按 raw source/context 查询 Rust TM 记录、复用判定和 provenance;需要管理 token |
| `GET /admin/translation/glossary/summary` | 读取 Rust Glossary schema 和 review-state 计数;需要管理 token |
| `GET /admin/translation/glossary/query?source_text=...&review_status=approved&limit=100` | 查询 Rust term、scope、source provenance 和 history;需要管理 token |
| `GET /admin/translation/glossary/diagnose?source_text=...&context=...` | 执行 deterministic Glossary constraints/diagnostics QA;需要管理 token |
| `GET /admin/translation/glossary/diagnose?source_text=...&context=...` | 执行 deterministic Glossary constraints/diagnostics QA 并返回 `qa_identity`;需要管理 token |
| `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 token |
| `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 |
@@ -186,16 +186,17 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
| `schedule-remove` | `schedule.remove` | `{ "id": "..." }` | `202` + Rust schedule report |
| `schedule-run` | `schedule.run` | 可选 `{ "id": "...", "force": true }` | `202` + 执行报告 |
| `task-cancel` | `task.cancel` | `{ "task_id": "..." }` | `202` + 取消请求结果 |
| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "completed", "provider": "manual", "provider_run_id": "...", "translation_results": [{ "unit_id": "...", "source_text": "...", "translated_text": "..." }] }` | `202` + 当前任务记录 |
| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "completed", "provider": "manual", "provider_run_id": "...", "translation_results": [{ "unit_id": "...", "source_text": "...", "translated_text": "...", "glossary_override": { "qa_identity": "...", "reviewer": "...", "reason": "...", "provenance": "...", "confirmed_unix_seconds": 1 } }] }` | `202` + 当前任务记录 |
| `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task |
| `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 |
| `translation-memory-confirm` | `translation.memory.confirm` | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | `202` + 已确认的 TM 记录 |
| `translation-memory-confirm` | `translation.memory.confirm` | `{ "record_id": "...", "reviewer": "...", "reason": "...", "supersede_record_id": "..." }` | `202` + 已确认的 TM 记录 |
| `translation-memory-resolve-conflict` | `translation.memory.resolve_conflict` | `{ "winner_record_id": "...", "expected_trusted_record_ids": ["..."], "reviewer": "...", "reason": "..." }` | `202` + 冲突解决报告 |
| `translation-glossary-add` | `translation.glossary.add` | term draft JSON | `202` + Glossary term |
| `translation-glossary-update` | `translation.glossary.update` | term draft + `reviewer` | `202` + Glossary term |
| `translation-glossary-approve` | `translation.glossary.approve` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + approved term |
| `translation-glossary-deprecate` | `translation.glossary.deprecate` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deprecated term |
| `translation-glossary-delete` | `translation.glossary.delete` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deleted term snapshot |
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }``{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest |
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }``{ "from_worker": true, "localized_release_id": "..." }``{ "patch_manifest": "...", "localized_release_id": "..." }` | `202` + localized release manifest |
| `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report |
`stop``clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。
@@ -248,6 +249,7 @@ curl -i -H 'Range: bytes=0-1023' \
| `--proxy <URL\|auto\|none>` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h |
| `--no-proxy` | 强制直连 |
| `--unzip <PATH>` | unzip 可执行文件(默认 `unzip` |
| `--zip <PATH>` | zip 可执行文件(默认 `zip` |
| `--dry-run` | 不写同步状态 |
| `--plan` | dry-run 时输出计划中的 URL |
| `--force` | 强制下载/刷新 |
@@ -294,12 +296,12 @@ curl -i -H 'Range: bytes=0-1023' \
- `config.toml` 的字段按职责分组:`[runtime]``[resource]``[localized]``[repository]``[network]``[translation.worker]`
- 现有 `BAT_*` 环境变量仍然有效,可继续覆盖 `config.toml` 中的同名配置。
- `BAT_SKIP_ENV_FILE` 已废弃且不再影响启动。
- 支持的环境变量:`BAT_OUTPUT``BAT_LOCALIZED_OUTPUT``BAT_STATE_DIR``BAT_AUTO_DISCOVER``BAT_WATCH``BAT_DAEMON``BAT_IMPORT_REPOSITORY``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB``BAT_PROXY``BAT_NO_PROXY``BAT_INTERVAL_SECONDS``BAT_ERROR_RETRY_SECONDS``BAT_APP_VERSION``BAT_CONNECTION_GROUP``BAT_LAUNCHER_VERSION``BAT_PLATFORMS``BAT_CURL``BAT_DOWNLOAD_CONCURRENCY``BAT_UNZIP``BAT_JSON``BAT_QUIET_UP_TO_DATE``BAT_TRANSLATION_PROVIDER``BAT_TRANSLATION_FIXTURE``BAT_TRANSLATION_MEMORY_PATH``BAT_GLOSSARY_PATH``BAT_TRANSLATION_CONCURRENCY``BAT_TRANSLATION_MAX_ATTEMPTS``BAT_TRANSLATION_LEASE_SECONDS``BAT_TRANSLATION_RETRY_BACKOFF_SECONDS``BAT_TRANSLATION_MAX_TASKS``BAT_TRANSLATION_WORKER_ID`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`
- 支持的环境变量:`BAT_OUTPUT``BAT_LOCALIZED_OUTPUT``BAT_STATE_DIR``BAT_AUTO_DISCOVER``BAT_WATCH``BAT_DAEMON``BAT_IMPORT_REPOSITORY``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB``BAT_PROXY``BAT_NO_PROXY``BAT_INTERVAL_SECONDS``BAT_ERROR_RETRY_SECONDS``BAT_APP_VERSION``BAT_CONNECTION_GROUP``BAT_LAUNCHER_VERSION``BAT_PLATFORMS``BAT_CURL``BAT_DOWNLOAD_CONCURRENCY``BAT_UNZIP``BAT_ZIP``BAT_JSON``BAT_QUIET_UP_TO_DATE``BAT_TRANSLATION_PROVIDER``BAT_TRANSLATION_FIXTURE``BAT_TRANSLATION_MEMORY_PATH``BAT_GLOSSARY_PATH``BAT_TRANSLATION_CONCURRENCY``BAT_TRANSLATION_MAX_ATTEMPTS``BAT_TRANSLATION_LEASE_SECONDS``BAT_TRANSLATION_RETRY_BACKOFF_SECONDS``BAT_TRANSLATION_MAX_TASKS``BAT_TRANSLATION_WORKER_ID`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`
- `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时运行模式设置让位。`status` / `verify` 等子命令不受它们影响。
- 已运行的 daemon 不会热读 `config.toml`;默认 `reload` 只唤醒后台重新发现和刷新。需要应用配置文件变更时,使用带显式启动参数的 `restart`/`reload`,或先 `stop` 再重新启动 daemon。
- `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json`
### Translation Memory V1
### Translation Memory persistence schema V2
Translation Memory 由 Rust `bat` 独立持有,默认路径为
`<output>/translation-memory.sqlite`,不在 `versions/<id>` 内,也不使用当前 release
@@ -310,16 +312,23 @@ Translation Memory 由 Rust `bat` 独立持有,默认路径为
bat i18n memory summary
bat i18n memory query --tm-source-text '原始文本' --tm-context-json '{"destination":"Table.bytes","archive_entry":"","field_path":"Text"}'
bat i18n memory confirm --tm-record-id 'tm-...' --tm-reviewer 'operator' --tm-reason '人工校对通过'
bat i18n memory conflicts --tm-limit 100
bat i18n memory resolve-conflict --tm-record-id 'tm-winner-...' \
--tm-expected-trusted-record-ids-json '["tm-winner-...","tm-loser-..."]' \
--tm-reviewer 'operator' --tm-reason '确认唯一译文'
```
只有 raw source 完全相同、完整 context 完全相同且状态为 `trusted` 的记录会被 worker
自动复用。provider 输出写入先是 `candidate`manual task result 即使 completed 也不会自动
建立 TM 或 trusted。查询、诊断和显式 confirm 对应 Rust
RPC `translation.memory.summary``translation.memory.query``translation.memory.confirm`
context 不完整或不一致、normalized source 辅助命中和 workflow `proofread` 都不会自动
建立 trusted 记录。
只有 raw source 完全相同、完整 context 完全相同且状态为 current `trusted`单条记录会被
worker 自动复用。provider 输出写入先是 `candidate`manual task result 即使 completed
也不会自动建立 TM 或 trusted。查询、诊断、确认和冲突治理对应 Rust
RPC `translation.memory.summary``translation.memory.query`
`translation.memory.confirm``translation.memory.conflicts`
`translation.memory.resolve_conflict`。同一 identity 存在多个 current Trusted 时,
查询返回 `trusted_conflict`,worker 禁止自动复用;确认不同译文必须显式指定 supersede
目标,历史冲突必须通过 resolve_conflict 选择稳定 record ID。context 不完整或不一致、
normalized source 辅助命中和 workflow `proofread` 都不会自动建立 trusted 记录。
### Glossary V1
### Glossary domain/feature contract V1SQLite persistence schema V2
Glossary 由 Rust `bat` 独立持有,默认路径为 `<output>/glossary.sqlite`,不位于
`versions/<id>`,也不与 TM 或当前 release 的 task 库共用。配置覆盖方式为
@@ -346,8 +355,9 @@ bat i18n glossary delete --glossary-term-id term-sensei \
scope 为空表示全局;同一 TextUnit 内冲突会 blockedpriority、scope specificity、
匹配长度和 term ID 使用确定性排序。允许但非推荐译法只产生 warning,系统不会在译文
生成后自动替换文本。provider、TM、人工 task update、workbench 和 localized publish
均执行相同 QAblocking deviation 需要 `reviewer``reason``provenance` 和确认时间
组成显式 override
均执行相同 QA每个具体 QA 都有稳定的 `qa_identity`。blocking deviation 需要
`qa_identity``reviewer``reason``provenance` 和确认时间组成显式 override
Glossary 相关定义变化会使受影响 override 失效,无关术语变化不会使其失效。
---
@@ -499,6 +509,10 @@ scope 为空表示全局;同一 TextUnit 内冲突会 blockedpriority、sco
| `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` |
| `resource.manifest` / `resource.list` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000 |
| `resource.index` | ✅ | 查询现有 SQLite ResourceRepository 索引,支持资源类型、hash、路径模式、release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 过滤 |
| `release.status` | ✅ | 查询 official/localized 当前与历史 release 的统一状态、来源关系和完整性 |
| `release.list` | ✅ | 按 namespace 查询历史 release 摘要,识别 current、legacy、stale 和 damaged |
| `release.distribution` | ✅ | 选择已验证的 official 或 localized 当前/历史 release;默认 official,不跨 channel fallback |
| `release.cleanup` | ✅ | 先 dry-run 生成 `plan_id`,再由 Rust 重验证引用后清理无引用历史 release |
| `parse.status` | ✅ | 查询当前 release 的解析缓存、TextUnit 索引和队列摘要 |
| `parse.text_units` / `parse.errors` | ✅ | 查询当前 release 的 TextUnit 明细和解析错误 |
| `translation.tasks` | ✅ | 查询离线 TextUnit 翻译任务及 worker 状态 |
@@ -521,10 +535,23 @@ scope 为空表示全局;同一 TextUnit 内冲突会 blockedpriority、sco
| 未知方法 | — | `BAT-ERR-700001`unknown method |
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` /
`resource.index` / `parse.*` / `translation.tasks` / `translation.handoff` /
`resource.index` / `release.status` / `release.list` / `release.distribution` / `parse.*` / `translation.tasks` / `translation.handoff` /
`localized.status` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本
或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
`localized.status` 会分别返回 `patch_manifest_contract_status` 和
`artifact_integrity_status`。current 仍存在但 release 文件被截断或手工修改时,
状态为 `degraded` / `localized.degraded`,不会自动回滚、删除或修复。`release.distribution`
默认选择 official;只有 Rust 已验证的当前或显式历史 release 可分发,localized、
staging、损坏或路径不安全的 release 不会回退到另一 channel。
`release.list` 的每项摘要还会返回 `rollback_available`、`stale`、`damaged`、
`referenced` 和 `unknown`,便于区分可回滚、损坏和证据不足的历史 release。
双 release cleanup 不是独立 CLI:通过 `release.cleanup` 先执行 dry-run,再把返回的
`plan_id` 传给 `execute=true`。Rust 会保护 current、rollback、staging、source、
状态/manifest/CAS 引用和未知归属对象;rollback 仍使用独立的 `localized.rollback`。
### 任务模型
`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }``status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
@@ -565,4 +592,16 @@ printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset":
# 触发本地资源审计+修复任务
printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
# 查询双 release 状态,并选择已验证的 localized release
printf '{"jsonrpc":"2.0","id":7,"method":"release.status"}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
printf '{"jsonrpc":"2.0","id":8,"method":"release.distribution","params":{"channel":"localized"}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
# cleanup 必须先 dry-run,再使用同一 plan_id 执行
printf '{"jsonrpc":"2.0","id":9,"method":"release.cleanup","params":{"execute":false}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
printf '{"jsonrpc":"2.0","id":10,"method":"release.cleanup","params":{"execute":true,"plan_id":"<plan-id>"}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
```
+129 -10
View File
@@ -20,25 +20,25 @@ paths:
summary: Release readiness
responses:
"200":
description: A distributable release is available.
description: A current official release authorized by Rust release.attestation and fully represented by the bound local read snapshot is available.
"503":
description: No distributable release is available.
description: The Rust current attestation is unavailable, stale, invalid, or the bound local read snapshot is not distributable.
/v1/bootstrap:
get:
summary: Startup resource bootstrap
responses:
"200":
description: Resource bootstrap response.
description: Resource bootstrap response with the same distribution health used by readiness and current CDN serving.
"503":
description: Release is not ready.
description: The current release is not distributable.
/v1/launcher/bootstrap:
get:
summary: Launcher-shaped resource bootstrap
responses:
"200":
description: Launcher bootstrap response.
description: Launcher bootstrap response with the current release distribution health.
"503":
description: Release is not ready.
description: The current release is not distributable.
/api/launcher/game/config:
get:
summary: Resource-only launcher game config compatibility
@@ -71,7 +71,58 @@ paths:
summary: Current release summary
responses:
"200":
description: Release summary.
description: Release summary including Rust-owned whole-release distribution health.
/v1/releases:
get:
summary: Rust-owned official and localized release history
parameters:
- name: channel
in: query
schema:
type: string
enum: [official, localized]
responses:
"200":
description: Release history and manifest/artifact integrity summaries.
"503":
description: Rust bat release backend is unavailable.
/v1/distribution:
get:
summary: Select a verified official or localized release for distribution
parameters:
- name: channel
in: query
schema:
type: string
enum: [official, localized]
default: official
- name: release_id
in: query
schema:
type: string
- name: destination
in: query
description: Optional release-relative path for single-entry lookup; Rust returns exactly one entry and revalidates the selected channel's actual bytes and BLAKE3.
schema:
type: string
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
responses:
"200":
description: Rust-verified selected release and resource manifest page.
"409":
description: Selected release is missing, stale, damaged, or not distributable.
"503":
description: Rust bat release backend is unavailable.
/v1/resources:
get:
summary: Paginated resource manifest entries
@@ -454,6 +505,31 @@ paths:
description: Missing or invalid admin token.
"503":
description: Rust bat Translation Memory backend is unavailable.
/admin/translation/memory/conflicts:
get:
summary: List Rust-owned Translation Memory Trusted conflicts
parameters:
- name: limit
in: query
schema:
type: integer
format: int64
minimum: 1
maximum: 1000
default: 100
- name: translation_memory_path
in: query
schema:
type: string
responses:
"200":
description: Translation Memory exact-identity Trusted conflict groups.
"400":
description: Invalid conflict list limit.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Translation Memory backend is unavailable.
/admin/translation/glossary/summary:
get:
summary: Read Rust-owned Glossary summary
@@ -527,7 +603,7 @@ paths:
type: string
responses:
"200":
description: Glossary constraints, diagnostics, and blocked decision.
description: Glossary constraints, diagnostics, blocked decision, and stable qa_identity.
"400":
description: Missing source text or invalid context.
"401":
@@ -544,6 +620,32 @@ paths:
description: Missing or invalid admin token.
"503":
description: Rust bat localized backend is unavailable.
/admin/releases/status:
get:
summary: Read the unified Rust-owned release status view
responses:
"200":
description: Official/localized current relation and integrity status.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat release backend is unavailable.
/admin/releases:
get:
summary: Read Rust-owned historical release summaries
parameters:
- name: channel
in: query
schema:
type: string
enum: [official, localized]
responses:
"200":
description: Historical release summaries.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat release backend is unavailable.
/admin/control/{action}:
post:
summary: Forward an allowlisted control or schedule action to Rust bat
@@ -553,7 +655,7 @@ paths:
required: true
schema:
type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback]
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-memory-resolve-conflict, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback, release-cleanup]
requestBody:
required: false
content:
@@ -621,9 +723,12 @@ paths:
type: string
glossary_override:
type: object
required: [reviewer, reason, provenance, confirmed_unix_seconds]
required: [qa_identity, reviewer, reason, provenance, confirmed_unix_seconds]
additionalProperties: false
properties:
qa_identity:
type: string
minLength: 1
reviewer:
type: string
reason:
@@ -665,6 +770,18 @@ paths:
type: string
record_id:
type: string
winner_record_id:
type: string
expected_trusted_record_ids:
type: array
items:
type: string
supersede_record_id:
type: string
reviewer:
type: string
reason:
type: string
term_id:
type: string
source_term:
@@ -720,6 +837,8 @@ paths:
type: string
from_worker:
type: boolean
patch_manifest:
type: string
localized_release_id:
type: string
responses:
-4
View File
@@ -9,7 +9,3 @@ func InspectManifest(rawJSON string) (string, error) {
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
return ffi.BuildSyncPlan(currentJSON, previousJSON)
}
func batVersion() (string, error) {
return ffi.Version()
}
+2 -2
View File
@@ -21,6 +21,6 @@ func runSync(args []string) error {
return err
}
fmt.Fprintln(os.Stdout, result)
return nil
_, err = fmt.Fprintln(os.Stdout, result)
return err
}
+1
View File
@@ -12,6 +12,7 @@ serde.workspace = true
serde_json.workspace = true
async-trait.workspace = true
tokio.workspace = true
blake3.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
+380 -13
View File
@@ -268,6 +268,9 @@ pub struct GlossaryDiagnostic {
/// Result of applying approved terms to one TextUnit source.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryEvaluation {
/// Stable identity of the glossary facts used to produce this evaluation.
#[serde(default)]
pub qa_identity: String,
/// Approved constraints sent to a provider.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub constraints: Vec<GlossaryConstraint>,
@@ -326,20 +329,30 @@ impl GlossaryEvaluation {
});
}
}
GlossaryQaReport {
status: if blocked {
GlossaryQaStatus::Blocked
} else if diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::NonRecommended)
{
GlossaryQaStatus::Warning
} else {
GlossaryQaStatus::Pass
},
let status = if blocked {
GlossaryQaStatus::Blocked
} else if diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::NonRecommended)
{
GlossaryQaStatus::Warning
} else {
GlossaryQaStatus::Pass
};
let mut report = GlossaryQaReport {
qa_identity: String::new(),
status,
constraints: self.constraints.clone(),
diagnostics,
}
};
report.qa_identity = output_qa_identity(
&self.qa_identity,
translated_text,
&report.status,
&report.constraints,
&report.diagnostics,
);
report
}
}
@@ -374,6 +387,9 @@ impl GlossaryQaStatus {
/// Persisted glossary QA attached to a translation result.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryQaReport {
/// Stable identity of the complete blocking-QA result.
#[serde(default)]
pub qa_identity: String,
/// QA status.
pub status: GlossaryQaStatus,
/// Constraints evaluated.
@@ -387,6 +403,9 @@ pub struct GlossaryQaReport {
/// Explicit human approval to deviate from a blocking glossary result.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryOverride {
/// Exact blocking-QA identity reviewed by the human.
#[serde(default)]
pub qa_identity: String,
/// Reviewer identity.
pub reviewer: String,
/// Required reason.
@@ -407,6 +426,7 @@ pub fn evaluate_glossary(
struct Candidate {
term_id: String,
matched_source: String,
definition: GlossaryTermSnapshot,
recommendation: String,
allowed: Vec<String>,
category: Option<String>,
@@ -436,6 +456,7 @@ pub fn evaluate_glossary(
candidates.push(Candidate {
term_id: term.term_id.clone(),
matched_source: spelling.clone(),
definition: term.definition.clone(),
recommendation: term.definition.recommended_translation.clone(),
allowed: term.definition.allowed_translations.clone(),
category: term.definition.category.clone(),
@@ -458,6 +479,18 @@ pub fn evaluate_glossary(
.then_with(|| left.term_id.cmp(&right.term_id))
.then_with(|| left.matched_source.cmp(&right.matched_source))
});
let identity_candidates = candidates
.iter()
.map(|candidate| GlossaryIdentityCandidate {
term_id: candidate.term_id.clone(),
matched_source: candidate.matched_source.clone(),
definition: candidate.definition.clone(),
priority: candidate.priority,
start: candidate.start,
end: candidate.end,
specificity: candidate.specificity,
})
.collect::<Vec<_>>();
let mut selected = Vec::new();
let mut diagnostics = Vec::new();
@@ -505,7 +538,7 @@ pub fn evaluate_glossary(
.then_with(|| left.matched_source.cmp(&right.matched_source))
});
let mut seen = BTreeSet::new();
let constraints = selected
let constraints: Vec<GlossaryConstraint> = selected
.into_iter()
.filter(|candidate| {
seen.insert((
@@ -524,13 +557,242 @@ pub fn evaluate_glossary(
scope: candidate.scope,
})
.collect();
let qa_identity = evaluation_identity(
source_text,
&identity_candidates,
&constraints,
&diagnostics,
);
GlossaryEvaluation {
qa_identity,
constraints,
diagnostics,
blocked,
}
}
/// Validates that an explicit human confirmation authorizes the current
/// blocking glossary result, rather than merely looking structurally complete.
pub fn validate_glossary_override(
qa: &GlossaryQaReport,
glossary_override: Option<&GlossaryOverride>,
) -> crate::Result<()> {
if !qa.status.is_blocked() {
return Err(crate::Error::InvalidArgument(
"Glossary override 只能用于 blocking QA".to_string(),
));
}
if qa.qa_identity.trim().is_empty() {
return Err(crate::Error::InvalidArgument(
"当前 Glossary QA 缺少 qa_identity,不能接受 override".to_string(),
));
}
let Some(glossary_override) = glossary_override else {
return Err(crate::Error::InvalidArgument(
"Glossary QA blocked;需要 reviewer、reason、provenance 和 qa_identity 显式确认"
.to_string(),
));
};
if glossary_override.reviewer.trim().is_empty()
|| glossary_override.reason.trim().is_empty()
|| glossary_override.provenance.trim().is_empty()
|| glossary_override.confirmed_unix_seconds == 0
{
return Err(crate::Error::InvalidArgument(
"Glossary override 的 reviewer、reason、provenance 和 confirmed_unix_seconds 必须有效"
.to_string(),
));
}
if glossary_override.qa_identity.trim().is_empty() {
return Err(crate::Error::InvalidArgument(
"Glossary override 缺少 qa_identity;旧 override 不能自动复用".to_string(),
));
}
if glossary_override.qa_identity != qa.qa_identity {
return Err(crate::Error::InvalidArgument(format!(
"Glossary override 的 qa_identity={} 与当前 QA={} 不一致",
glossary_override.qa_identity, qa.qa_identity
)));
}
Ok(())
}
const GLOSSARY_QA_IDENTITY_VERSION: &[u8] = b"bat-glossary-qa-v1";
fn evaluation_identity(
source_text: &str,
candidates: &[GlossaryIdentityCandidate],
constraints: &[GlossaryConstraint],
diagnostics: &[GlossaryDiagnostic],
) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(GLOSSARY_QA_IDENTITY_VERSION);
hash_string(&mut hasher, source_text);
let mut candidates = candidates.iter().collect::<Vec<_>>();
candidates.sort_by_key(|candidate| candidate.identity_sort_key());
hash_len(&mut hasher, candidates.len());
for candidate in candidates {
candidate.hash_identity(&mut hasher);
}
hash_constraints(&mut hasher, constraints);
hash_diagnostics(&mut hasher, diagnostics);
format!("gqa-v1-{}", hasher.finalize().to_hex())
}
fn output_qa_identity(
evaluation_identity: &str,
translated_text: &str,
status: &GlossaryQaStatus,
constraints: &[GlossaryConstraint],
diagnostics: &[GlossaryDiagnostic],
) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(GLOSSARY_QA_IDENTITY_VERSION);
hash_string(&mut hasher, evaluation_identity);
hash_string(&mut hasher, translated_text);
hash_string(&mut hasher, status.as_str());
hash_constraints(&mut hasher, constraints);
hash_diagnostics(&mut hasher, diagnostics);
format!("gqa-v1-{}", hasher.finalize().to_hex())
}
struct GlossaryIdentityCandidate {
term_id: String,
matched_source: String,
definition: GlossaryTermSnapshot,
priority: i32,
start: usize,
end: usize,
specificity: usize,
}
impl GlossaryIdentityCandidate {
fn identity_sort_key(&self) -> (String, String, usize, usize, i32, usize) {
(
self.term_id.clone(),
self.matched_source.clone(),
self.start,
self.end,
self.priority,
self.specificity,
)
}
fn hash_identity(&self, hasher: &mut blake3::Hasher) {
hash_string(hasher, &self.term_id);
hash_string(hasher, &self.matched_source);
hash_usize(hasher, self.start);
hash_usize(hasher, self.end);
hash_i32(hasher, self.priority);
hash_usize(hasher, self.specificity);
hash_snapshot(hasher, &self.definition);
}
}
fn hash_len(hasher: &mut blake3::Hasher, value: usize) {
hasher.update(&(value as u64).to_le_bytes());
}
fn hash_usize(hasher: &mut blake3::Hasher, value: usize) {
hasher.update(&(value as u64).to_le_bytes());
}
fn hash_i32(hasher: &mut blake3::Hasher, value: i32) {
hasher.update(&value.to_le_bytes());
}
fn hash_bool(hasher: &mut blake3::Hasher, value: bool) {
hasher.update(&[value as u8]);
}
fn hash_string(hasher: &mut blake3::Hasher, value: &str) {
hash_len(hasher, value.len());
hasher.update(value.as_bytes());
}
fn hash_option_string(hasher: &mut blake3::Hasher, value: Option<&str>) {
match value {
Some(value) => {
hash_bool(hasher, true);
hash_string(hasher, value);
}
None => hash_bool(hasher, false),
}
}
fn hash_string_list(hasher: &mut blake3::Hasher, values: &[String]) {
let mut values = values.to_vec();
values.sort();
values.dedup();
hash_len(hasher, values.len());
for value in values {
hash_string(hasher, &value);
}
}
fn hash_map(hasher: &mut blake3::Hasher, values: &BTreeMap<String, String>) {
hash_len(hasher, values.len());
for (key, value) in values {
hash_string(hasher, key);
hash_string(hasher, value);
}
}
fn hash_snapshot(hasher: &mut blake3::Hasher, snapshot: &GlossaryTermSnapshot) {
hash_string(hasher, &snapshot.source_term);
hash_string_list(hasher, &snapshot.aliases);
hash_string(hasher, &snapshot.recommended_translation);
hash_string_list(hasher, &snapshot.allowed_translations);
hash_option_string(hasher, snapshot.source_language.as_deref());
hash_option_string(hasher, snapshot.target_language.as_deref());
hash_option_string(hasher, snapshot.category.as_deref());
hash_i32(hasher, snapshot.priority);
hash_map(hasher, &snapshot.scope);
}
fn hash_constraints(hasher: &mut blake3::Hasher, constraints: &[GlossaryConstraint]) {
let mut constraints = constraints.to_vec();
constraints.sort_by(|left, right| {
left.term_id
.cmp(&right.term_id)
.then_with(|| left.matched_source.cmp(&right.matched_source))
.then_with(|| left.priority.cmp(&right.priority))
.then_with(|| {
left.recommended_translation
.cmp(&right.recommended_translation)
})
});
hash_len(hasher, constraints.len());
for constraint in constraints {
hash_string(hasher, &constraint.term_id);
hash_string(hasher, &constraint.matched_source);
hash_string(hasher, &constraint.recommended_translation);
hash_string_list(hasher, &constraint.allowed_translations);
hash_option_string(hasher, constraint.category.as_deref());
hash_i32(hasher, constraint.priority);
hash_map(hasher, &constraint.scope);
}
}
fn hash_diagnostics(hasher: &mut blake3::Hasher, diagnostics: &[GlossaryDiagnostic]) {
let mut diagnostics = diagnostics.to_vec();
diagnostics.sort_by(|left, right| {
left.kind
.as_str()
.cmp(right.kind.as_str())
.then_with(|| left.term_id.cmp(&right.term_id))
.then_with(|| left.value.cmp(&right.value))
.then_with(|| left.message.cmp(&right.message))
});
hash_len(hasher, diagnostics.len());
for diagnostic in diagnostics {
hash_string(hasher, diagnostic.kind.as_str());
hash_option_string(hasher, diagnostic.term_id.as_deref());
hash_option_string(hasher, diagnostic.value.as_deref());
hash_string(hasher, &diagnostic.message);
}
}
/// Validates and normalizes a term draft without choosing a review status.
pub fn validate_glossary_draft(draft: &GlossaryTermDraft) -> crate::Result<()> {
if draft.term_id.trim().is_empty()
@@ -681,6 +943,111 @@ mod tests {
.diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::Violation));
assert!(!blocked.qa_identity.is_empty());
}
#[test]
fn qa_identity_is_stable_for_input_order_and_unrelated_terms() {
let mut first = term("first", "Sensei", "老师", 1, BTreeMap::new());
first.definition.aliases = vec!["Teacher".to_string(), "Sensei".to_string()];
let mut second = first.clone();
second.definition.aliases.reverse();
let unrelated = term("unrelated", "Other", "其他", 1, BTreeMap::new());
let source = "Teacher";
let context = BTreeMap::new();
let first_qa = evaluate_glossary(&[first.clone(), unrelated.clone()], source, &context)
.check_translation("先生");
let reordered_qa =
evaluate_glossary(&[unrelated, second], source, &context).check_translation("先生");
assert_eq!(first_qa.qa_identity, reordered_qa.qa_identity);
let mut changed_unrelated = term("unrelated", "Other", "别的译法", 1, BTreeMap::new());
changed_unrelated.definition.allowed_translations = vec!["其他".to_string()];
let unrelated_changed_qa = evaluate_glossary(&[first, changed_unrelated], source, &context)
.check_translation("先生");
assert_eq!(first_qa.qa_identity, unrelated_changed_qa.qa_identity);
}
#[test]
fn qa_identity_changes_when_relevant_glossary_facts_change() {
let base = term("sensei", "Sensei", "老师", 1, BTreeMap::new());
let base_qa = evaluate_glossary(std::slice::from_ref(&base), "Sensei", &BTreeMap::new())
.check_translation("先生");
let mut recommended = base.clone();
recommended.definition.recommended_translation = "教师".to_string();
let recommended_qa =
evaluate_glossary(&[recommended], "Sensei", &BTreeMap::new()).check_translation("先生");
assert_ne!(base_qa.qa_identity, recommended_qa.qa_identity);
let mut allowed = base.clone();
allowed.definition.allowed_translations = vec!["先生".to_string()];
let allowed_qa =
evaluate_glossary(&[allowed], "Sensei", &BTreeMap::new()).check_translation("先生");
assert_ne!(base_qa.qa_identity, allowed_qa.qa_identity);
let mut scoped = base.clone();
scoped
.definition
.scope
.insert("destination".to_string(), "story".to_string());
let scoped_qa =
evaluate_glossary(&[scoped], "Sensei", &BTreeMap::new()).check_translation("先生");
assert_ne!(base_qa.qa_identity, scoped_qa.qa_identity);
let mut aliased = base.clone();
aliased.definition.source_term = "Instructor".to_string();
aliased.definition.aliases = vec!["Sensei".to_string()];
let aliased_qa =
evaluate_glossary(&[aliased], "Sensei", &BTreeMap::new()).check_translation("先生");
assert_ne!(base_qa.qa_identity, aliased_qa.qa_identity);
let conflict = vec![
term("a", "Sensei", "老师", 1, BTreeMap::new()),
term("b", "Sensei", "导师", 1, BTreeMap::new()),
];
let conflict_qa =
evaluate_glossary(&conflict, "Sensei", &BTreeMap::new()).check_translation("先生");
let mut priority_changed = conflict;
priority_changed[1].definition.priority = 2;
let priority_qa = evaluate_glossary(&priority_changed, "Sensei", &BTreeMap::new())
.check_translation("先生");
assert_ne!(conflict_qa.qa_identity, priority_qa.qa_identity);
}
#[test]
fn override_must_match_current_blocking_qa_identity() {
let evaluation = evaluate_glossary(
&[term("sensei", "Sensei", "老师", 1, BTreeMap::new())],
"Sensei",
&BTreeMap::new(),
);
let qa = evaluation.check_translation("先生");
let mut override_record = GlossaryOverride {
qa_identity: qa.qa_identity.clone(),
reviewer: "reviewer".to_string(),
reason: "manual review".to_string(),
provenance: "workbench".to_string(),
confirmed_unix_seconds: 1,
};
assert!(validate_glossary_override(&qa, Some(&override_record)).is_ok());
override_record.qa_identity.clear();
assert!(validate_glossary_override(&qa, Some(&override_record)).is_err());
override_record.qa_identity = "gqa-v1-old".to_string();
assert!(validate_glossary_override(&qa, Some(&override_record)).is_err());
let old_override: GlossaryOverride = serde_json::from_str(
r#"{
"reviewer": "reviewer",
"reason": "manual review",
"provenance": "workbench",
"confirmed_unix_seconds": 1
}"#,
)
.unwrap();
assert!(validate_glossary_override(&qa, Some(&old_override)).is_err());
}
#[test]
+7 -6
View File
@@ -10,9 +10,9 @@ pub mod translation_memory;
pub use game_client::{ClientStatus, GameClient, GameRegion};
pub use game_version::{GameVersion, UnityVersion};
pub use glossary::{
evaluate_glossary, validate_glossary_draft, GlossaryConstraint, GlossaryDiagnostic,
GlossaryDiagnosticKind, GlossaryEvaluation, GlossaryHistoryRecord, GlossaryOverride,
GlossaryQaReport, GlossaryQaStatus, GlossaryReviewStatus, GlossarySourceKind,
evaluate_glossary, validate_glossary_draft, validate_glossary_override, GlossaryConstraint,
GlossaryDiagnostic, GlossaryDiagnosticKind, GlossaryEvaluation, GlossaryHistoryRecord,
GlossaryOverride, GlossaryQaReport, GlossaryQaStatus, GlossaryReviewStatus, GlossarySourceKind,
GlossarySourceRecord, GlossarySummary, GlossaryTerm, GlossaryTermDraft, GlossaryTermSnapshot,
};
pub use resource::{
@@ -23,7 +23,8 @@ pub use translation::{
TranslationStatus,
};
pub use translation_memory::{
TranslationMemoryContext, TranslationMemoryDraft, TranslationMemoryEntry,
TranslationMemoryMatch, TranslationMemoryMatchKind, TranslationMemorySourceKind,
TranslationMemorySourceTrace, TranslationMemorySummary, TranslationMemoryTrustStatus,
TranslationMemoryConflict, TranslationMemoryContext, TranslationMemoryDraft,
TranslationMemoryEntry, TranslationMemoryMatch, TranslationMemoryMatchKind,
TranslationMemorySourceKind, TranslationMemorySourceTrace, TranslationMemorySummary,
TranslationMemoryTrustStatus,
};
+24
View File
@@ -57,6 +57,8 @@ impl TranslationMemoryTrustStatus {
pub enum TranslationMemoryMatchKind {
/// 原始 source 和上下文都完全匹配,且记录可信,可自动复用。
StrongExact,
/// 同一 exact identity 存在多个 Trusted,必须人工治理。
TrustedConflict,
/// 原始 source 完全匹配,但上下文不同或不足,不能自动复用。
CandidateExact,
/// 原始 source 匹配,但上下文不兼容,不能自动复用。
@@ -68,6 +70,7 @@ impl TranslationMemoryMatchKind {
pub const fn as_str(&self) -> &'static str {
match self {
Self::StrongExact => "strong_exact",
Self::TrustedConflict => "trusted_conflict",
Self::CandidateExact => "candidate_exact",
Self::SourceOnly => "source_only",
}
@@ -210,6 +213,23 @@ pub struct TranslationMemoryMatch {
pub can_auto_reuse: bool,
}
/// 一个 exact identity 的历史多 Trusted 冲突组。
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TranslationMemoryConflict {
/// 原始 source text。
pub source_text: String,
/// source text hash,仅用于稳定定位和辅助查询。
pub source_hash: String,
/// 完整 source context。
pub source_context: TranslationMemoryContext,
/// source context hash,仅用于稳定定位和辅助查询。
pub source_context_hash: String,
/// 当前数据库中属于该冲突组的 Trusted record ID。
pub trusted_record_ids: Vec<String>,
/// 冲突组记录及其原始 trust provenance。
pub records: Vec<TranslationMemoryEntry>,
}
/// TM 仓储摘要。
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TranslationMemorySummary {
@@ -225,4 +245,8 @@ pub struct TranslationMemorySummary {
pub superseded_count: u64,
/// 已拒绝记录数。
pub rejected_count: u64,
/// exact identity 的 Trusted 冲突组数量。
pub trusted_conflict_group_count: u64,
/// 具备 current Trusted authorization 的 exact identity 数量。
pub current_trusted_count: u64,
}
@@ -1,14 +1,14 @@
//! Translation Memory 仓储契约。
use crate::domain::{
TranslationMemoryContext, TranslationMemoryDraft, TranslationMemoryEntry,
TranslationMemoryMatch, TranslationMemorySummary,
TranslationMemoryConflict, TranslationMemoryContext, TranslationMemoryDraft,
TranslationMemoryEntry, TranslationMemoryMatch, TranslationMemorySummary,
};
use async_trait::async_trait;
/// 跨 official release 持久化的 Translation Memory 仓储。
///
/// 该契约描述 V1 的精确查询和明确人工确认。仓储实现不得把
/// 该契约描述 exact-match TM 和明确人工 Trusted 治理。仓储实现不得把
/// `TranslationTaskStatus::Completed` 或 provider 成功隐式解释为 trusted。
#[async_trait]
pub trait TranslationMemoryRepository: Send + Sync {
@@ -39,6 +39,27 @@ pub trait TranslationMemoryRepository: Send + Sync {
reason: Option<String>,
) -> crate::Result<TranslationMemoryEntry>;
/// 确认记录,并在需要时显式 supersede 当前唯一 Trusted。
async fn confirm_with_supersede(
&self,
record_id: &str,
reviewer: &str,
reason: Option<String>,
supersede_record_id: Option<&str>,
) -> crate::Result<TranslationMemoryEntry>;
/// 列出历史上存在多个 Trusted 的 exact identity 冲突组。
async fn list_conflicts(&self, limit: usize) -> crate::Result<Vec<TranslationMemoryConflict>>;
/// 使用事务内精确的 expected set 显式解决一个 Trusted 冲突组。
async fn resolve_conflict(
&self,
winner_record_id: &str,
expected_trusted_record_ids: &[String],
reviewer: &str,
reason: &str,
) -> crate::Result<TranslationMemoryEntry>;
/// 按稳定记录 ID 读取一条 TM 记录。
async fn find(&self, record_id: &str) -> crate::Result<TranslationMemoryEntry>;
+2 -2
View File
@@ -17,8 +17,8 @@ pub mod types;
pub use error::{AssetBundleError, Result};
pub use parser::{compression_from_flags, Parser, UnityFsParser};
pub use patch::{
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
StringFieldPatch, TextAssetPatch,
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset,
rebuild_unityfs_bundle, FieldPatch, StringFieldPatch, TextAssetPatch,
};
pub use serialized::{
UnityManagedReferenceMetadata, UnityManagedReferenceRecord, UnitySerializedField,
+1
View File
@@ -174,6 +174,7 @@ fn parse_unityfs(data: &[u8]) -> Result<UnityFsBundle> {
compressed_data_size,
uncompressed_data_size,
raw_data: data.to_vec(),
uncompressed_data,
files,
serialized_files,
text_assets,
+864 -30
View File
@@ -5,7 +5,7 @@ use crate::parser::UnityFsParser;
use crate::serialized::{
UnitySerializedField, UnitySerializedReplacementValue, UnitySerializedValue,
};
use crate::types::UnityFsBundle;
use crate::types::{UnityFsBundle, UnityFsCompression};
use md5::{Digest, Md5};
/// One TextAsset replacement inside a serialized UnityFS directory file.
@@ -105,9 +105,9 @@ impl FieldPatch {
/// Patches one TextAsset and rebuilds the UnityFS container.
///
/// The rebuilt bundle uses a single uncompressed data block. This keeps the
/// patch path deterministic and avoids relying on a compressor-specific
/// implementation while preserving all directory file paths and metadata.
/// The rebuilt bundle retains the parsed block count, compression modes,
/// alignment flags and directory metadata while recalculating all variable
/// offsets and sizes.
pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<Vec<u8>> {
let parser = UnityFsParser::new();
let mut bundle = parser.parse_bytes(data)?;
@@ -142,8 +142,25 @@ pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<V
let rebuilt = rebuild_unityfs(&bundle)?;
let verified = parser.parse_bytes(&rebuilt)?;
let asset = verified
.text_assets
verify_rebuild_preserves_unmodified_content(
&bundle,
&verified,
&patch.serialized_file_path,
patch.path_id,
None,
)?;
let verified_serialized = verified
.serialized_files
.iter()
.find(|file| file.source_path.as_deref() == Some(patch.serialized_file_path.as_str()))
.ok_or_else(|| {
AssetBundleError::Parse(format!(
"patched serialized file {} was not found after rebuild",
patch.serialized_file_path
))
})?;
let asset = verified_serialized
.text_assets()
.iter()
.find(|asset| asset.path_id == patch.path_id)
.ok_or_else(|| {
@@ -197,6 +214,13 @@ pub fn patch_unityfs_string_field(data: &[u8], patch: &StringFieldPatch) -> Resu
let rebuilt = rebuild_unityfs(&bundle)?;
let verified = parser.parse_bytes(&rebuilt)?;
verify_rebuild_preserves_unmodified_content(
&bundle,
&verified,
&patch.serialized_file_path,
patch.path_id,
Some(&patch.field_path),
)?;
let serialized = verified
.serialized_files
.iter()
@@ -259,6 +283,13 @@ pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result<Vec<u8>> {
let rebuilt = rebuild_unityfs(&bundle)?;
let verified = parser.parse_bytes(&rebuilt)?;
verify_rebuild_preserves_unmodified_content(
&bundle,
&verified,
&patch.serialized_file_path,
patch.path_id,
Some(&patch.field_path),
)?;
let serialized = verified
.serialized_files
.iter()
@@ -285,7 +316,12 @@ pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result<Vec<u8>> {
Ok(rebuilt)
}
fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
/// Rebuilds a parsed UnityFS bundle while preserving its container shape.
///
/// The directory order, path, flags, header version and block compression
/// modes are retained. Variable-length file changes are reflected in
/// directory offsets and block sizes; no raw offset is patched blindly.
pub fn rebuild_unityfs_bundle(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
if bundle.files.len() != bundle.directories.len() {
return Err(AssetBundleError::Parse(format!(
"UnityFS file/directory count mismatch: files={}, directories={}",
@@ -293,24 +329,123 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
bundle.directories.len()
)));
}
let mut uncompressed_data = Vec::new();
let mut directory_offsets = Vec::with_capacity(bundle.files.len());
for file in &bundle.files {
let offset = u64::try_from(uncompressed_data.len())
.map_err(|_| AssetBundleError::Parse("UnityFS data offset overflow".to_string()))?;
directory_offsets.push(offset);
uncompressed_data.extend_from_slice(&file.data);
let block_uncompressed_size = bundle
.blocks
.iter()
.try_fold(0u64, |total, block| {
total.checked_add(u64::from(block.uncompressed_size))
})
.ok_or_else(|| AssetBundleError::Parse("UnityFS block size overflow".to_string()))?;
let retained_uncompressed_size = u64::try_from(bundle.uncompressed_data.len())
.map_err(|_| AssetBundleError::Parse("UnityFS data size does not fit u64".to_string()))?;
if block_uncompressed_size != retained_uncompressed_size
|| bundle.uncompressed_data_size != retained_uncompressed_size
{
return Err(AssetBundleError::Parse(format!(
"UnityFS block table/data size mismatch: blocks={}, retained={}, declared={}",
block_uncompressed_size, retained_uncompressed_size, bundle.uncompressed_data_size
)));
}
let mut ordered_directories = bundle.directories.iter().enumerate().collect::<Vec<_>>();
ordered_directories.sort_by_key(|(_, directory)| directory.offset);
let mut uncompressed_data = Vec::with_capacity(bundle.uncompressed_data.len());
let mut directory_offsets = vec![0u64; bundle.files.len()];
let mut cursor = 0usize;
for (index, directory) in ordered_directories {
let file = bundle.files.get(index).ok_or_else(|| {
AssetBundleError::Parse(format!(
"UnityFS directory {} has no corresponding file",
directory.path
))
})?;
if file.path != directory.path {
return Err(AssetBundleError::Parse(format!(
"UnityFS file/directory path mismatch: file={:?}, directory={:?}",
file.path, directory.path
)));
}
let start = usize::try_from(directory.offset).map_err(|_| {
AssetBundleError::Parse(format!(
"UnityFS directory {} offset does not fit usize",
directory.path
))
})?;
let end = start
.checked_add(usize::try_from(directory.size).map_err(|_| {
AssetBundleError::Parse(format!(
"UnityFS directory {} size does not fit usize",
directory.path
))
})?)
.ok_or_else(|| {
AssetBundleError::Parse(format!(
"UnityFS directory {} range overflows usize",
directory.path
))
})?;
if start < cursor || end > bundle.uncompressed_data.len() {
return Err(AssetBundleError::Parse(format!(
"UnityFS directory {} overlaps or exceeds the original data region",
directory.path
)));
}
uncompressed_data.extend_from_slice(&bundle.uncompressed_data[cursor..start]);
directory_offsets[index] = u64::try_from(uncompressed_data.len()).map_err(|_| {
AssetBundleError::Parse("UnityFS directory offset overflow".to_string())
})?;
uncompressed_data.extend_from_slice(&file.data);
cursor = end;
}
uncompressed_data.extend_from_slice(&bundle.uncompressed_data[cursor..]);
let block_sizes = repartition_blocks(uncompressed_data.len(), &bundle.blocks)?;
let mut compressed_blocks = Vec::with_capacity(bundle.blocks.len());
for (index, (block, size)) in bundle.blocks.iter().zip(&block_sizes).enumerate() {
let start = block_sizes[..index].iter().sum::<usize>();
let end = start.checked_add(*size).ok_or_else(|| {
AssetBundleError::Parse(format!("UnityFS rebuilt block {index} range overflows"))
})?;
let bytes = uncompressed_data.get(start..end).ok_or_else(|| {
AssetBundleError::Parse(format!(
"UnityFS rebuilt block {index} range {}..{} exceeds data {}",
start,
end,
uncompressed_data.len()
))
})?;
compressed_blocks.push(compress_unityfs_bytes(
bytes,
block.compression,
&format!("UnityFS data block {index}"),
)?);
}
let data_size = u32::try_from(uncompressed_data.len()).map_err(|_| {
AssetBundleError::Parse("UnityFS rebuilt data exceeds u32 size".to_string())
})?;
let mut blocks_info_body = Vec::new();
push_i32_be(&mut blocks_info_body, 1);
push_u32_be(&mut blocks_info_body, data_size);
push_u32_be(&mut blocks_info_body, data_size);
push_u16_be(&mut blocks_info_body, 0);
push_i32_be(
&mut blocks_info_body,
i32::try_from(bundle.blocks.len())
.map_err(|_| AssetBundleError::Parse("UnityFS block count exceeds i32".to_string()))?,
);
for (block, (bytes, uncompressed_size)) in bundle
.blocks
.iter()
.zip(compressed_blocks.iter().zip(block_sizes.iter()))
{
push_u32_be(
&mut blocks_info_body,
u32::try_from(*uncompressed_size).map_err(|_| {
AssetBundleError::Parse("UnityFS rebuilt block exceeds u32 size".to_string())
})?,
);
push_u32_be(
&mut blocks_info_body,
u32::try_from(bytes.len()).map_err(|_| {
AssetBundleError::Parse("UnityFS compressed block exceeds u32 size".to_string())
})?,
);
push_u16_be(&mut blocks_info_body, block.flags);
}
push_i32_be(
&mut blocks_info_body,
i32::try_from(bundle.files.len()).map_err(|_| {
@@ -328,9 +463,14 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
push_c_string(&mut blocks_info_body, &file.path);
}
let digest = Md5::digest(&blocks_info_body);
let mut blocks_info = Vec::with_capacity(16 + blocks_info_body.len());
blocks_info.extend_from_slice(&digest);
blocks_info.extend_from_slice(&blocks_info_body);
let mut blocks_info_uncompressed = Vec::with_capacity(16 + blocks_info_body.len());
blocks_info_uncompressed.extend_from_slice(&digest);
blocks_info_uncompressed.extend_from_slice(&blocks_info_body);
let blocks_info = compress_unityfs_bytes(
&blocks_info_uncompressed,
compression_from_header_flags(bundle.header.flags),
"UnityFS block info",
)?;
let mut output = Vec::new();
push_c_string(&mut output, "UnityFS");
@@ -342,27 +482,395 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
push_u32_be(
&mut output,
u32::try_from(blocks_info.len()).map_err(|_| {
AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string())
AssetBundleError::Parse("UnityFS compressed block info exceeds u32 size".to_string())
})?,
);
push_u32_be(
&mut output,
u32::try_from(blocks_info.len()).map_err(|_| {
u32::try_from(blocks_info_uncompressed.len()).map_err(|_| {
AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string())
})?,
);
push_u32_be(&mut output, 0);
push_u32_be(&mut output, bundle.header.flags);
if bundle.header.format_version >= 7 {
align_vec(&mut output, 16);
}
output.extend_from_slice(&blocks_info);
output.extend_from_slice(&uncompressed_data);
if block_info_at_end(bundle.header.flags) {
if block_data_aligned(bundle.header.flags) {
align_vec(&mut output, 16);
}
for bytes in &compressed_blocks {
output.extend_from_slice(bytes);
}
output.extend_from_slice(&blocks_info);
} else {
output.extend_from_slice(&blocks_info);
if block_data_aligned(bundle.header.flags) {
align_vec(&mut output, 16);
}
for bytes in &compressed_blocks {
output.extend_from_slice(bytes);
}
}
let total_size = u64::try_from(output.len())
.map_err(|_| AssetBundleError::Parse("UnityFS rebuilt size overflow".to_string()))?;
output[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
Ok(output)
}
fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
rebuild_unityfs_bundle(bundle)
}
fn compression_from_header_flags(flags: u32) -> UnityFsCompression {
crate::parser::compression_from_flags((flags & 0x3f) as u16)
}
fn block_info_at_end(flags: u32) -> bool {
flags & 0x80 != 0
}
fn block_data_aligned(flags: u32) -> bool {
flags & 0x200 != 0
}
fn repartition_blocks(
total_size: usize,
blocks: &[crate::types::UnityFsBlockInfo],
) -> Result<Vec<usize>> {
if blocks.is_empty() {
return Err(AssetBundleError::Parse(
"UnityFS bundle has no blocks to rebuild".to_string(),
));
}
let original_total = blocks
.iter()
.try_fold(0u64, |total, block| {
total.checked_add(u64::from(block.uncompressed_size))
})
.ok_or_else(|| AssetBundleError::Parse("UnityFS block size overflow".to_string()))?;
if original_total == 0 {
return Ok(vec![0; blocks.len()]);
}
let total = u64::try_from(total_size)
.map_err(|_| AssetBundleError::Parse("UnityFS data size does not fit u64".to_string()))?;
let mut result = Vec::with_capacity(blocks.len());
let mut previous = 0u64;
let mut cumulative = 0u64;
for block in blocks {
cumulative = cumulative
.checked_add(u64::from(block.uncompressed_size))
.ok_or_else(|| {
AssetBundleError::Parse("UnityFS block boundary overflow".to_string())
})?;
let boundary = total.checked_mul(cumulative).ok_or_else(|| {
AssetBundleError::Parse("UnityFS block boundary overflow".to_string())
})? / original_total;
result.push(usize::try_from(boundary - previous).map_err(|_| {
AssetBundleError::Parse("UnityFS rebuilt block size does not fit usize".to_string())
})?);
previous = boundary;
}
Ok(result)
}
fn compress_unityfs_bytes(
data: &[u8],
compression: UnityFsCompression,
context: &str,
) -> Result<Vec<u8>> {
match compression {
UnityFsCompression::None => Ok(data.to_vec()),
UnityFsCompression::Lz4 => {
lz4::block::compress(data, Some(lz4::block::CompressionMode::FAST(1)), false).map_err(
|error| {
AssetBundleError::Parse(format!("Failed to compress {context} as LZ4: {error}"))
},
)
}
UnityFsCompression::Lz4Hc => lz4::block::compress(
data,
Some(lz4::block::CompressionMode::HIGHCOMPRESSION(9)),
false,
)
.map_err(|error| {
AssetBundleError::Parse(format!("Failed to compress {context} as LZ4HC: {error}"))
}),
UnityFsCompression::Lzma => {
let mut output = Vec::new();
lzma_rs::lzma_compress(&mut std::io::Cursor::new(data), &mut output).map_err(
|error| {
AssetBundleError::Parse(format!(
"Failed to compress {context} as LZMA: {error}"
))
},
)?;
Ok(output)
}
UnityFsCompression::Unknown(value) => Err(AssetBundleError::UnsupportedFormat(format!(
"unsupported {context} compression flag: {value}"
))),
}
}
fn verify_rebuild_preserves_unmodified_content(
original: &UnityFsBundle,
rebuilt_bytes: &UnityFsBundle,
modified_serialized_path: &str,
modified_path_id: i64,
modified_field_path: Option<&str>,
) -> Result<()> {
if original.header.format_version != rebuilt_bytes.header.format_version
|| original.header.target_version != rebuilt_bytes.header.target_version
|| original.header.unity_version != rebuilt_bytes.header.unity_version
|| original.header.flags != rebuilt_bytes.header.flags
{
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed immutable header metadata".to_string(),
));
}
if original.blocks.len() != rebuilt_bytes.blocks.len()
|| original.directories.len() != rebuilt_bytes.directories.len()
{
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed block or directory count".to_string(),
));
}
if original.serialized_files.len() != rebuilt_bytes.serialized_files.len() {
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed serialized-file count".to_string(),
));
}
if original.serialized_parse_errors != rebuilt_bytes.serialized_parse_errors {
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed serialized-file diagnostics".to_string(),
));
}
for (original, rebuilt) in original.blocks.iter().zip(&rebuilt_bytes.blocks) {
if original.flags != rebuilt.flags || original.compression != rebuilt.compression {
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed block compression metadata".to_string(),
));
}
}
for (original, rebuilt) in original.directories.iter().zip(&rebuilt_bytes.directories) {
if original.path != rebuilt.path || original.flags != rebuilt.flags {
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed directory path or flags".to_string(),
));
}
}
for (original_file, rebuilt_file) in original.files.iter().zip(&rebuilt_bytes.files) {
if original_file.path != modified_serialized_path
&& (original_file.path != rebuilt_file.path || original_file.data != rebuilt_file.data)
{
return Err(AssetBundleError::Parse(format!(
"UnityFS rebuild changed an unmodified directory file: {}",
original_file.path
)));
}
}
for original_file in &original.serialized_files {
let rebuilt_file = rebuilt_bytes
.serialized_files
.iter()
.find(|candidate| candidate.source_path == original_file.source_path)
.ok_or_else(|| {
AssetBundleError::Parse(format!(
"UnityFS rebuild lost serialized file {:?}",
original_file.source_path
))
})?;
if original_file.source_path != rebuilt_file.source_path
|| original_file.version != rebuilt_file.version
|| original_file.unity_version != rebuilt_file.unity_version
|| original_file.platform != rebuilt_file.platform
|| original_file.types != rebuilt_file.types
|| original_file.objects.len() != rebuilt_file.objects.len()
{
return Err(AssetBundleError::Parse(
"UnityFS rebuild changed serialized-file metadata".to_string(),
));
}
for (original_object, rebuilt_object) in
original_file.objects.iter().zip(&rebuilt_file.objects)
{
if original_object.path_id != rebuilt_object.path_id
|| original_object.type_index != rebuilt_object.type_index
|| original_object.class_id != rebuilt_object.class_id
{
return Err(AssetBundleError::Parse(format!(
"UnityFS rebuild changed object table entry path_id {}",
original_object.path_id
)));
}
let is_modified_object = original_file.source_path.as_deref()
== Some(modified_serialized_path)
&& original_object.path_id == modified_path_id;
if !is_modified_object && original_object.byte_size != rebuilt_object.byte_size {
return Err(AssetBundleError::Parse(format!(
"UnityFS rebuild changed unmodified object byte size path_id {}",
original_object.path_id
)));
}
if is_modified_object {
if let Some(field_path) = modified_field_path {
let original_fields = original_file.fields_for_object_entry(original_object)?;
let rebuilt_fields = rebuilt_file.fields_for_object_entry(rebuilt_object)?;
verify_unmodified_fields(
&original_fields,
&rebuilt_fields,
field_path,
original_object.path_id,
)?;
} else {
let original_asset = original_file
.text_assets()
.iter()
.find(|asset| asset.path_id == modified_path_id);
let rebuilt_asset = rebuilt_file
.text_assets()
.iter()
.find(|asset| asset.path_id == modified_path_id);
if original_asset.map(|asset| &asset.name)
!= rebuilt_asset.map(|asset| &asset.name)
{
return Err(AssetBundleError::Parse(format!(
"TextAsset path_id {} name changed while rebuilding",
modified_path_id
)));
}
}
} else {
if original_file.object_bytes(original_object)?
!= rebuilt_file.object_bytes(rebuilt_object)?
{
return Err(AssetBundleError::Parse(format!(
"UnityFS rebuild changed unmodified object path_id {}",
original_object.path_id
)));
}
}
}
}
Ok(())
}
fn verify_unmodified_fields(
original: &[UnitySerializedField],
rebuilt: &[UnitySerializedField],
modified_field_path: &str,
path_id: i64,
) -> Result<()> {
if original.len() != rebuilt.len() {
return Err(AssetBundleError::Parse(format!(
"Unity object path_id {path_id} changed field count while rebuilding"
)));
}
for (original, rebuilt) in original.iter().zip(rebuilt) {
if original.path != rebuilt.path
|| original.name != rebuilt.name
|| original.type_name != rebuilt.type_name
|| original.type_tree_node_index != rebuilt.type_tree_node_index
{
return Err(AssetBundleError::Parse(format!(
"Unity object path_id {path_id} changed field metadata at {}",
original.path
)));
}
if !field_path_contains(&original.path, modified_field_path)
&& original.byte_size != rebuilt.byte_size
{
return Err(AssetBundleError::Parse(format!(
"Unity object path_id {path_id} changed unmodified field byte size at {}",
original.path
)));
}
if original.path == modified_field_path {
continue;
}
verify_unmodified_values(
&original.value,
&rebuilt.value,
modified_field_path,
path_id,
)?;
}
Ok(())
}
fn field_path_contains(parent: &str, child: &str) -> bool {
child == parent
|| child
.strip_prefix(parent)
.is_some_and(|suffix| suffix.starts_with('.') || suffix.starts_with('['))
}
fn verify_unmodified_values(
original: &UnitySerializedValue,
rebuilt: &UnitySerializedValue,
modified_field_path: &str,
path_id: i64,
) -> Result<()> {
match (original, rebuilt) {
(UnitySerializedValue::Object(original), UnitySerializedValue::Object(rebuilt))
| (UnitySerializedValue::Array(original), UnitySerializedValue::Array(rebuilt))
| (UnitySerializedValue::Map(original), UnitySerializedValue::Map(rebuilt)) => {
verify_unmodified_fields(original, rebuilt, modified_field_path, path_id)
}
(
UnitySerializedValue::ManagedReference {
type_name: original_type,
metadata: original_metadata,
fields: original_fields,
bytes: original_bytes,
},
UnitySerializedValue::ManagedReference {
type_name: rebuilt_type,
metadata: rebuilt_metadata,
fields: rebuilt_fields,
bytes: rebuilt_bytes,
},
) => {
if original_type != rebuilt_type
|| original_metadata != rebuilt_metadata
|| original_bytes != rebuilt_bytes
{
return Err(AssetBundleError::Parse(format!(
"Unity object path_id {path_id} changed managed-reference metadata"
)));
}
verify_unmodified_fields(
original_fields,
rebuilt_fields,
modified_field_path,
path_id,
)
}
(
UnitySerializedValue::ManagedReferenceRegistry {
references: _,
fields: original_fields,
},
UnitySerializedValue::ManagedReferenceRegistry {
references: _,
fields: rebuilt_fields,
},
) => verify_unmodified_fields(
original_fields,
rebuilt_fields,
modified_field_path,
path_id,
),
(original, rebuilt) if original != rebuilt => Err(AssetBundleError::Parse(format!(
"Unity object path_id {path_id} changed an unmodified field value"
))),
_ => Ok(()),
}
}
fn find_field_value<'a>(
fields: &'a [UnitySerializedField],
field_path: &str,
@@ -489,6 +997,99 @@ mod tests {
data
}
fn synthetic_bundle_with_compressed_blocks(
files: &[(&str, &[u8])],
block_flags: u16,
header_flags: u32,
) -> Vec<u8> {
let mut uncompressed = Vec::new();
let mut directory_offsets = Vec::with_capacity(files.len());
for (_, bytes) in files {
directory_offsets.push(uncompressed.len() as u64);
uncompressed.extend_from_slice(bytes);
}
let split = uncompressed.len() / 2;
let split = if uncompressed.is_empty() {
0
} else {
split.max(1).min(uncompressed.len())
};
let chunks = [&uncompressed[..split], &uncompressed[split..]];
let compressed_chunks = chunks
.iter()
.map(|chunk| match block_flags & 0x3f {
0 => chunk.to_vec(),
1 => {
let mut output = Vec::new();
lzma_rs::lzma_compress(&mut std::io::Cursor::new(chunk), &mut output).unwrap();
output
}
2..=4 => lz4::block::compress(chunk, None, false).unwrap(),
value => panic!("unsupported fixture compression {value}"),
})
.collect::<Vec<_>>();
let mut block_info_body = Vec::new();
push_i32_be(&mut block_info_body, chunks.len() as i32);
for (chunk, compressed) in chunks.iter().zip(&compressed_chunks) {
push_u32_be(&mut block_info_body, chunk.len() as u32);
push_u32_be(&mut block_info_body, compressed.len() as u32);
push_u16_be(&mut block_info_body, block_flags);
}
push_i32_be(&mut block_info_body, files.len() as i32);
for ((path, bytes), offset) in files.iter().zip(directory_offsets) {
push_u64_be(&mut block_info_body, offset);
push_u64_be(&mut block_info_body, bytes.len() as u64);
push_u32_be(&mut block_info_body, 0);
push_c_string(&mut block_info_body, path);
}
let mut block_info = vec![0; 16];
block_info.extend_from_slice(&block_info_body);
let block_info = match header_flags & 0x3f {
0 => block_info,
1 => {
let mut output = Vec::new();
lzma_rs::lzma_compress(&mut std::io::Cursor::new(&block_info), &mut output)
.unwrap();
output
}
2..=4 => lz4::block::compress(&block_info, None, false).unwrap(),
value => panic!("unsupported fixture block-info compression {value}"),
};
let mut data = Vec::new();
push_c_string(&mut data, "UnityFS");
push_u32_be(&mut data, 8);
push_c_string(&mut data, "5.x.x");
push_c_string(&mut data, "2021.3.56f2");
let total_size_offset = data.len();
push_u64_be(&mut data, 0);
push_u32_be(&mut data, block_info.len() as u32);
push_u32_be(&mut data, (16 + block_info_body.len()) as u32);
push_u32_be(&mut data, header_flags);
align_vec(&mut data, 16);
if header_flags & 0x80 != 0 {
if header_flags & 0x200 != 0 {
align_vec(&mut data, 16);
}
for compressed in &compressed_chunks {
data.extend_from_slice(compressed);
}
data.extend_from_slice(&block_info);
} else {
data.extend_from_slice(&block_info);
if header_flags & 0x200 != 0 {
align_vec(&mut data, 16);
}
for compressed in &compressed_chunks {
data.extend_from_slice(compressed);
}
}
let total_size = data.len() as u64;
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
data
}
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}
@@ -549,6 +1150,66 @@ mod tests {
file
}
fn synthetic_serialized_two_text_assets(first: &[u8], second: &[u8]) -> Vec<u8> {
fn text_asset_object(bytes: &[u8]) -> Vec<u8> {
let mut object = Vec::new();
push_u32_le(&mut object, 8);
object.extend_from_slice(b"Scenario");
align_vec(&mut object, 4);
push_u32_le(&mut object, bytes.len() as u32);
object.extend_from_slice(bytes);
object
}
let first_object = text_asset_object(first);
let second_object = text_asset_object(second);
let mut object_data = Vec::new();
let first_offset = object_data.len() as u64;
object_data.extend_from_slice(&first_object);
let first_size = object_data.len() as u64 - first_offset;
let second_offset = object_data.len() as u64;
object_data.extend_from_slice(&second_object);
let second_size = object_data.len() as u64 - second_offset;
let mut metadata = Vec::new();
metadata.extend_from_slice(b"2021.3.56f2\0");
push_i32_le(&mut metadata, 19);
metadata.push(0);
push_i32_le(&mut metadata, 1);
push_i32_le(&mut metadata, 49);
metadata.push(0);
push_i16_le(&mut metadata, 0);
metadata.extend_from_slice(&[0; 16]);
push_i32_le(&mut metadata, 2);
align_vec(&mut metadata, 4);
for (path_id, offset, size) in [
(1i64, first_offset, first_size),
(2, second_offset, second_size),
] {
metadata.extend_from_slice(&path_id.to_le_bytes());
push_u64_le(&mut metadata, offset);
push_u32_le(&mut metadata, size as u32);
push_i32_le(&mut metadata, 0);
}
let header_len = 48usize;
let data_offset = header_len + metadata.len();
let file_size = data_offset + object_data.len();
let mut file = Vec::new();
push_u32_be(&mut file, metadata.len() as u32);
push_u32_be(&mut file, file_size as u32);
push_u32_be(&mut file, 22);
push_u32_be(&mut file, 0);
file.extend_from_slice(&[0, 0, 0, 0]);
push_u32_be(&mut file, metadata.len() as u32);
push_u64_be(&mut file, file_size as u64);
push_u64_be(&mut file, data_offset as u64);
push_u64_be(&mut file, 0);
file.extend_from_slice(&metadata);
file.extend_from_slice(&object_data);
file
}
fn synthetic_serialized_monobehaviour() -> Vec<u8> {
let mut object_data = Vec::new();
push_u32_le(&mut object_data, 5);
@@ -1391,6 +2052,179 @@ mod tests {
assert_eq!(parsed.unity_version, reparsed.unity_version);
}
#[test]
fn rebuild_preserves_compressed_blocks_alignment_and_untouched_files() {
let serialized = synthetic_serialized_text_asset(b"old");
let source = synthetic_bundle_with_compressed_blocks(
&[("CAB-scenario", &serialized), ("CAB-untouched", b"keep")],
2,
2 | 0x200,
);
let parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
assert_eq!(parsed.blocks.len(), 2);
assert_eq!(parsed.blocks[0].compression, UnityFsCompression::Lz4);
assert_eq!(parsed.header.flags, 2 | 0x200);
let patched = patch_unityfs_text_asset(
&source,
&TextAssetPatch {
serialized_file_path: "CAB-scenario".to_string(),
path_id: 1,
expected_name: Some("Scenario".to_string()),
replacement: b"a longer localized payload".to_vec(),
},
)
.unwrap();
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
assert_eq!(reparsed.blocks.len(), 2);
assert_eq!(reparsed.blocks[0].compression, UnityFsCompression::Lz4);
assert_eq!(reparsed.blocks[1].compression, UnityFsCompression::Lz4);
assert_eq!(reparsed.header.flags, parsed.header.flags);
assert_eq!(
reparsed
.files
.iter()
.find(|file| file.path == "CAB-untouched")
.unwrap()
.data,
b"keep"
);
assert_eq!(reparsed.text_assets[0].bytes, b"a longer localized payload");
}
#[test]
fn rebuild_preserves_unmodified_serialized_objects() {
let serialized = synthetic_serialized_two_text_assets(b"old", b"keep-object");
let source = synthetic_bundle_with_path(&serialized, "CAB-scenario");
let patched = patch_unityfs_text_asset(
&source,
&TextAssetPatch {
serialized_file_path: "CAB-scenario".to_string(),
path_id: 1,
expected_name: Some("Scenario".to_string()),
replacement: b"localized".to_vec(),
},
)
.unwrap();
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
assert_eq!(
reparsed
.text_assets
.iter()
.find(|asset| asset.path_id == 1)
.unwrap()
.bytes,
b"localized"
);
assert_eq!(
reparsed
.text_assets
.iter()
.find(|asset| asset.path_id == 2)
.unwrap()
.bytes,
b"keep-object"
);
}
#[test]
fn post_rebuild_text_asset_verification_scopes_duplicate_path_id_to_serialized_file() {
let first = synthetic_serialized_text_asset(b"first");
let second = synthetic_serialized_text_asset(b"second");
let source = synthetic_bundle_with_compressed_blocks(
&[("CAB-first", &first), ("CAB-second", &second)],
0,
0,
);
let patched = patch_unityfs_text_asset(
&source,
&TextAssetPatch {
serialized_file_path: "CAB-second".to_string(),
path_id: 1,
expected_name: Some("Scenario".to_string()),
replacement: b"localized-second".to_vec(),
},
)
.unwrap();
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
assert_eq!(
reparsed
.serialized_files
.iter()
.find(|file| file.source_path.as_deref() == Some("CAB-first"))
.unwrap()
.text_assets()[0]
.bytes,
b"first"
);
assert_eq!(
reparsed
.serialized_files
.iter()
.find(|file| file.source_path.as_deref() == Some("CAB-second"))
.unwrap()
.text_assets()[0]
.bytes,
b"localized-second"
);
}
#[test]
fn rebuild_preserves_block_info_at_end_and_lzma_compression() {
let serialized = synthetic_serialized_text_asset(b"old");
let source = synthetic_bundle_with_compressed_blocks(
&[("CAB-scenario", &serialized)],
1,
1 | 0x80 | 0x200,
);
let patched = patch_unityfs_text_asset(
&source,
&TextAssetPatch {
serialized_file_path: "CAB-scenario".to_string(),
path_id: 1,
expected_name: None,
replacement: b"localized".to_vec(),
},
)
.unwrap();
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
assert_eq!(reparsed.header.flags, 1 | 0x80 | 0x200);
assert_eq!(reparsed.blocks[0].compression, UnityFsCompression::Lzma);
assert_eq!(reparsed.blocks[1].compression, UnityFsCompression::Lzma);
assert_eq!(reparsed.text_assets[0].bytes, b"localized");
}
#[test]
fn rebuild_rejects_unknown_compression_without_guessing() {
let source = synthetic_bundle(b"payload");
let mut parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
parsed.blocks[0].compression = UnityFsCompression::Unknown(63);
let error = rebuild_unityfs_bundle(&parsed).unwrap_err();
assert!(matches!(
error,
AssetBundleError::UnsupportedFormat(message)
if message.contains("unsupported UnityFS data block 0 compression flag: 63")
));
}
#[test]
fn rebuild_rejects_inconsistent_uncompressed_block_table() {
let source = synthetic_bundle(b"payload");
let mut parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
parsed.blocks[0].uncompressed_size += 1;
let error = rebuild_unityfs_bundle(&parsed).unwrap_err().to_string();
assert!(error.contains("block table/data size mismatch"), "{error}");
}
#[test]
fn patches_text_asset_and_verifies_reparsed_payload() {
let original_text = "こんにちは".as_bytes();
+28
View File
@@ -1088,6 +1088,34 @@ impl UnitySerializedFile {
}
}
/// Returns the raw payload bytes for one object table entry.
///
/// The returned slice is still owned by the parsed serialized file. It is
/// useful to compare untouched objects across a variable-length rebuild.
pub fn object_bytes(&self, object: &UnitySerializedObject) -> Result<&[u8]> {
let object_start = self
.data_offset
.checked_add(usize::try_from(object.byte_start).map_err(|_| {
AssetBundleError::Parse(format!(
"Unity object path_id {} byte_start does not fit usize",
object.path_id
))
})?)
.ok_or_else(|| AssetBundleError::Parse("Unity object offset overflow".to_string()))?;
let object_end = object_start
.checked_add(object.byte_size as usize)
.ok_or_else(|| AssetBundleError::Parse("Unity object size overflow".to_string()))?;
self.raw_data.get(object_start..object_end).ok_or_else(|| {
AssetBundleError::Parse(format!(
"Unity object path_id {} byte range {}..{} exceeds file size {}",
object.path_id,
object_start,
object_end,
self.raw_data.len()
))
})
}
fn rewrite_object_payload(
&self,
target_index: usize,
+3
View File
@@ -62,6 +62,9 @@ pub struct UnityFsBundle {
pub uncompressed_data_size: u64,
/// Original bytes retained for future extraction/serialization.
pub raw_data: Vec<u8>,
/// Uncompressed data region retained so rebuilds can preserve gaps and
/// trailing bytes that are not represented by directory entries.
pub(crate) uncompressed_data: Vec<u8>,
/// Files extracted from the UnityFS uncompressed data region.
pub files: Vec<UnityFsFile>,
/// Serialized files parsed from UnityFS directory files.
+1
View File
@@ -13,6 +13,7 @@ serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
async-trait.workspace = true
libc = "0.2"
# 文件系统操作
tokio = { workspace = true, features = ["fs", "io-util"] }
+117
View File
@@ -84,6 +84,22 @@ impl SqliteRefCounter {
)
.await?;
let _release_reference_table = Self::execute_query(
&self.pool,
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS cas_release_references (
release_id TEXT NOT NULL,
ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
object_id TEXT NOT NULL,
released INTEGER NOT NULL CHECK(released IN (0, 1)),
PRIMARY KEY(release_id, ordinal)
)
"#,
),
)
.await?;
Ok(())
}
@@ -283,6 +299,107 @@ impl SqliteRefCounter {
.await?;
Ok(result.rows_affected() > 0)
}
/// Atomically releases one durable release ownership record.
///
/// The ownership row and the reference decrement are committed in the
/// same SQLite transaction. Retrying the same `(ownership_id, ordinal)` is
/// therefore idempotent, while a different ownership keeps its own row and
/// reference count. The legacy SQL column name is retained for schema
/// compatibility.
pub async fn release_reference_once(
&self,
ownership_id: &str,
ordinal: u64,
hash: &Hash,
) -> Result<bool> {
let mut transaction = self.pool.begin().await?;
let existing: Option<(String, i64)> = sqlx::query_as(
r#"
SELECT object_id, released
FROM cas_release_references
WHERE release_id = ?1 AND ordinal = ?2
"#,
)
.bind(ownership_id)
.bind(ordinal as i64)
.fetch_optional(&mut *transaction)
.await?;
if let Some((object_id, released)) = existing {
if object_id != hash.to_string() {
return Err(CasError::Other(anyhow::anyhow!(
"CAS release ownership mismatch: ownership_id={} ordinal={} expected={} actual={}",
ownership_id,
ordinal,
object_id,
hash
)));
}
if released != 0 {
transaction.commit().await?;
return Ok(false);
}
return Err(CasError::Other(anyhow::anyhow!(
"CAS release ownership record is not in a retryable state: ownership_id={} ordinal={}",
ownership_id,
ordinal
)));
}
let now = Self::now();
let updated: Option<i64> = sqlx::query_scalar(
r#"
UPDATE cas_objects
SET ref_count = ref_count - 1,
updated_at = ?1,
zero_ref_at = CASE WHEN ref_count = 1 THEN ?1 ELSE zero_ref_at END
WHERE hash = ?2 AND ref_count > 0
RETURNING ref_count
"#,
)
.bind(now)
.bind(hash.to_string())
.fetch_optional(&mut *transaction)
.await?;
if updated.is_none() {
let exists: Option<i64> =
sqlx::query_scalar("SELECT ref_count FROM cas_objects WHERE hash = ?1")
.bind(hash.to_string())
.fetch_optional(&mut *transaction)
.await?;
if exists.is_some() {
return Err(CasError::ReferenceUnderflow(hash.to_string()));
}
return Err(CasError::ObjectNotFound(hash.to_string()));
}
sqlx::query(
r#"
INSERT INTO cas_release_references(release_id, ordinal, object_id, released)
VALUES(?1, ?2, ?3, 1)
"#,
)
.bind(ownership_id)
.bind(ordinal as i64)
.bind(hash.to_string())
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(true)
}
/// Returns whether the durable ledger contains any row for an ownership.
pub async fn has_release_ownership(&self, ownership_id: &str) -> Result<bool> {
let exists: i64 = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM cas_release_references WHERE release_id = ?1)",
)
.bind(ownership_id)
.fetch_one(&self.pool)
.await?;
Ok(exists != 0)
}
}
#[cfg(test)]
+124 -2
View File
@@ -4,7 +4,25 @@ use crate::error::{CasError, Result};
use crate::hash::{compute_hash, Hash};
use crate::refcount::SqliteRefCounter;
use crate::storage::{FileSystemStorage, Storage, StorageStats};
use std::fs::OpenOptions;
use std::path::Path;
use std::path::PathBuf;
struct CasOperationLock {
file: std::fs::File,
}
impl Drop for CasOperationLock {
fn drop(&mut self) {
#[cfg(unix)]
unsafe {
libc::flock(
std::os::unix::io::AsRawFd::as_raw_fd(&self.file),
libc::LOCK_UN,
);
}
}
}
/// 文件系统 CAS repository。
///
@@ -34,8 +52,16 @@ impl FileSystemCasRepository {
&self.storage
}
async fn acquire_operation_lock(&self) -> Result<CasOperationLock> {
let path = self.storage.root().join(".cas-operation.lock");
tokio::task::spawn_blocking(move || acquire_operation_lock_sync(path))
.await
.map_err(|error| CasError::Other(anyhow::anyhow!("CAS lock task failed: {error}")))?
}
/// 存储对象并增加引用计数。
pub async fn store(&self, data: &[u8]) -> Result<Hash> {
let _lock = self.acquire_operation_lock().await?;
let hash = compute_hash(data);
let existed = self.storage.exists(&hash).await?;
let stored_hash = self.storage.put(data).await?;
@@ -70,17 +96,20 @@ impl FileSystemCasRepository {
/// 读取对象并验证 Hash。
pub async fn get(&self, hash: &Hash) -> Result<Vec<u8>> {
let _lock = self.acquire_operation_lock().await?;
let data = self.storage.get(hash).await?;
Ok(data)
}
/// 检查对象是否存在。
pub async fn exists(&self, hash: &Hash) -> Result<bool> {
let _lock = self.acquire_operation_lock().await?;
self.storage.exists(hash).await
}
/// 增加引用计数。
pub async fn add_reference(&self, hash: &Hash) -> Result<u64> {
let _lock = self.acquire_operation_lock().await?;
if !self.storage.exists(hash).await? {
return Err(CasError::ObjectNotFound(hash.to_string()));
}
@@ -94,22 +123,26 @@ impl FileSystemCasRepository {
/// 减少引用计数。
pub async fn remove_reference(&self, hash: &Hash) -> Result<u64> {
let _lock = self.acquire_operation_lock().await?;
self.ref_counter.remove_reference(hash).await
}
/// 获取引用计数。
pub async fn get_reference_count(&self, hash: &Hash) -> Result<u64> {
let _lock = self.acquire_operation_lock().await?;
self.ref_counter.get_reference_count(hash).await
}
/// 返回当前 GC 候选对象。
pub async fn gc_candidates(&self) -> Result<Vec<Hash>> {
self.ref_counter.zero_ref_objects().await
let _lock = self.acquire_operation_lock().await?;
self.gc_candidates_unlocked().await
}
/// 删除引用计数为 0 的对象。
pub async fn gc(&self) -> Result<u64> {
let candidates = self.gc_candidates().await?;
let _lock = self.acquire_operation_lock().await?;
let candidates = self.gc_candidates_unlocked().await?;
let mut deleted = 0u64;
for hash in candidates {
@@ -130,10 +163,52 @@ impl FileSystemCasRepository {
Ok(deleted)
}
/// Releases one release-owned reference exactly once.
pub async fn release_reference_once(
&self,
ownership_id: &str,
ordinal: u64,
hash: &Hash,
) -> Result<bool> {
let _lock = self.acquire_operation_lock().await?;
self.ref_counter
.release_reference_once(ownership_id, ordinal, hash)
.await
}
/// Returns whether the durable release ownership ledger has any row.
pub async fn has_release_ownership(&self, ownership_id: &str) -> Result<bool> {
let _lock = self.acquire_operation_lock().await?;
self.ref_counter.has_release_ownership(ownership_id).await
}
/// 获取存储统计信息。
pub async fn stats(&self) -> Result<StorageStats> {
let _lock = self.acquire_operation_lock().await?;
self.storage.stats().await
}
async fn gc_candidates_unlocked(&self) -> Result<Vec<Hash>> {
self.ref_counter.zero_ref_objects().await
}
}
fn acquire_operation_lock_sync(path: PathBuf) -> Result<CasOperationLock> {
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(path)?;
#[cfg(unix)]
{
let result =
unsafe { libc::flock(std::os::unix::io::AsRawFd::as_raw_fd(&file), libc::LOCK_EX) };
if result != 0 {
return Err(CasError::Io(std::io::Error::last_os_error()));
}
}
Ok(CasOperationLock { file })
}
#[cfg(test)]
@@ -228,6 +303,53 @@ mod tests {
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 1);
}
#[tokio::test]
async fn cross_repository_gc_and_store_preserve_object_lifetime() {
let (temp_dir, repo) = temp_repo().await;
let hash = repo.store(b"cross-process lifetime").await.unwrap();
assert_eq!(repo.remove_reference(&hash).await.unwrap(), 0);
let other = FileSystemCasRepository::new(temp_dir.path()).await.unwrap();
let (gc_result, store_result) =
tokio::join!(repo.gc(), other.store(b"cross-process lifetime"));
gc_result.unwrap();
assert_eq!(store_result.unwrap(), hash);
assert_eq!(other.get_reference_count(&hash).await.unwrap(), 1);
assert_eq!(other.get(&hash).await.unwrap(), b"cross-process lifetime");
}
#[tokio::test]
async fn release_reference_is_idempotent_after_retry() {
let (_temp_dir, repo) = temp_repo().await;
let hash = repo.store(b"owned").await.unwrap();
assert!(repo
.release_reference_once("release-a", 0, &hash)
.await
.unwrap());
assert!(!repo
.release_reference_once("release-a", 0, &hash)
.await
.unwrap());
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 0);
}
#[tokio::test]
async fn release_reference_ownership_is_scoped_per_release() {
let (_temp_dir, repo) = temp_repo().await;
let hash = repo.store(b"shared ownership").await.unwrap();
assert_eq!(repo.add_reference(&hash).await.unwrap(), 2);
assert!(repo
.release_reference_once("release-a", 0, &hash)
.await
.unwrap());
assert!(repo
.release_reference_once("release-b", 0, &hash)
.await
.unwrap());
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 0);
}
#[tokio::test]
async fn corrupted_object_is_detected_through_repository() {
let (_temp_dir, repo) = temp_repo().await;
+3 -2
View File
@@ -19,8 +19,9 @@ pub mod text;
pub use error::{PatchError, Result};
pub use manifest::{
PatchIntegrity, PatchKind, PatchManifest, PatchManifestFile, PatchRollback,
PATCH_MANIFEST_VERSION,
build_patch_manifest, validate_patch_manifest, verify_patch_file_bytes, PatchIntegrity,
PatchKind, PatchManifest, PatchManifestBuildFile, PatchManifestFile, PatchManifestOperation,
PatchManifestOperationPayload, PatchManifestProvenance, PatchRollback, PATCH_MANIFEST_VERSION,
};
/// Patch 引擎版本号
+891 -9
View File
@@ -1,7 +1,8 @@
//! Patch manifest, integrity and rollback primitives.
use crate::PatchError;
use crate::{binary::BinaryPatch, json::JsonPatchOperation, text::TextPatch, PatchError};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use std::path::{Component, Path, PathBuf};
@@ -52,10 +53,30 @@ pub struct PatchManifestFile {
pub source_size: u64,
/// Expected target byte length.
pub target_size: u64,
/// Ordered operations that produce the target bytes.
#[serde(default)]
pub operations: Vec<PatchManifestOperation>,
}
/// Input specification for building one manifest file from verified source and
/// target release roots.
///
/// Operation payloads and provenance are deliberately independent from
/// filesystem metadata. This keeps the manifest builder usable for UnityFS operations,
/// whose bytes are produced by `bat-assetbundle`, while still requiring the
/// resulting source and target files to exist and match the recorded manifest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchManifestBuildFile {
/// Release-relative path.
pub path: PathBuf,
/// Patch kind declared for this file.
pub patch_kind: PatchKind,
/// Ordered operations, including archive and provenance metadata.
pub operations: Vec<PatchManifestOperation>,
}
/// Patch algorithm family used by one manifest file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PatchKind {
/// Deterministic binary hunk patch.
@@ -65,7 +86,185 @@ pub enum PatchKind {
/// UTF-8 text patch.
Text,
/// UnityFS TextAsset replacement patch.
#[serde(rename = "unityfs_text_asset")]
UnityFsTextAsset,
/// UnityFS TypeTree string-field replacement patch.
#[serde(rename = "unityfs_string_field")]
UnityFsStringField,
/// UnityFS semantic TypeTree field replacement patch.
#[serde(rename = "unityfs_field")]
UnityFsField,
/// A file containing more than one supported operation kind.
Mixed,
}
/// One ordered, auditable operation in a manifest file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchManifestOperation {
/// Stable zero-based order within the file.
pub sequence: u32,
/// Optional BLAKE3 hash of the bytes immediately before this operation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_blake3: Option<String>,
/// Optional size of the bytes immediately before this operation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_size: Option<u64>,
/// Optional archive entry for a UnityFS bundle nested in a ZIP.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive_entry: Option<String>,
/// Algorithm payload and UnityFS target location.
#[serde(flatten)]
pub payload: PatchManifestOperationPayload,
/// Translation and review provenance, when this operation came from a
/// localized workflow.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<PatchManifestProvenance>,
}
/// Supported operation payloads in the generic manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PatchManifestOperationPayload {
/// Deterministic binary hunk patch.
Binary {
/// Complete binary patch document.
patch: BinaryPatch,
},
/// RFC 6902 JSON Patch document.
Json {
/// JSON Patch array. It is retained as JSON to preserve the wire
/// contract while the algorithm crate validates each operation.
patch: Value,
},
/// UTF-8 text patch.
Text {
/// Complete text patch document.
patch: TextPatch,
},
/// UnityFS TextAsset replacement.
#[serde(rename = "unityfs_text_asset")]
UnityFsTextAsset {
/// Serialized file path in the UnityFS directory table.
serialized_file_path: String,
/// Unity object path ID.
path_id: i64,
/// Optional expected TextAsset name.
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_name: Option<String>,
/// Replacement bytes.
replacement: Vec<u8>,
},
/// UnityFS TypeTree string field replacement.
#[serde(rename = "unityfs_string_field")]
UnityFsStringField {
/// Serialized file path in the UnityFS directory table.
serialized_file_path: String,
/// Unity object path ID.
path_id: i64,
/// TypeTree field path.
field_path: String,
/// Optional expected source string.
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_value: Option<String>,
/// Replacement string.
replacement: String,
},
/// UnityFS semantic TypeTree field replacement.
#[serde(rename = "unityfs_field")]
UnityFsField {
/// Serialized file path in the UnityFS directory table.
serialized_file_path: String,
/// Unity object path ID.
path_id: i64,
/// TypeTree field path.
field_path: String,
/// Optional expected semantic source value.
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_value: Option<Value>,
/// Replacement semantic value using the Unity serialized value schema.
replacement: Value,
},
}
impl PatchManifestOperationPayload {
/// Returns the file-level patch kind represented by this payload.
pub fn patch_kind(&self) -> PatchKind {
match self {
Self::Binary { .. } => PatchKind::Binary,
Self::Json { .. } => PatchKind::Json,
Self::Text { .. } => PatchKind::Text,
Self::UnityFsTextAsset { .. } => PatchKind::UnityFsTextAsset,
Self::UnityFsStringField { .. } => PatchKind::UnityFsStringField,
Self::UnityFsField { .. } => PatchKind::UnityFsField,
}
}
fn validate(&self) -> crate::Result<()> {
match self {
Self::Binary { patch } if patch.version != crate::binary::BINARY_PATCH_VERSION => {
return Err(PatchError::ApplyFailed(format!(
"unsupported binary patch version {}",
patch.version
)))
}
Self::Text { patch } if patch.version != crate::text::TEXT_PATCH_VERSION => {
return Err(PatchError::ApplyFailed(format!(
"unsupported text patch version {}",
patch.version
)))
}
Self::Json { patch } => {
serde_json::from_value::<Vec<JsonPatchOperation>>(patch.clone()).map_err(
|error| {
PatchError::ApplyFailed(format!(
"invalid JSON patch operation list: {error}"
))
},
)?;
}
_ => {}
}
Ok(())
}
}
/// Provenance retained for a localized operation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchManifestProvenance {
/// Stable TextUnit identifier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_unit_id: Option<String>,
/// BLAKE3 of the validated source text.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_text_blake3: Option<String>,
/// Translation provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_provider: Option<String>,
/// Provider run identifier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_run_id: Option<String>,
/// Translation source kind.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_source_kind: Option<String>,
/// Trusted Translation Memory record identifier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_memory_record_id: Option<String>,
/// Review status.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_status: Option<String>,
/// Deterministic Glossary QA report.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<Value>,
/// Explicit Glossary QA override.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<Value>,
}
impl PatchManifestOperation {
/// Returns the operation kind.
pub fn patch_kind(&self) -> PatchKind {
self.payload.patch_kind()
}
}
/// Rollback metadata owned by higher-level publication code.
@@ -94,12 +293,7 @@ pub fn verify_patch_manifest_files(
target_root: &Path,
manifest: &PatchManifest,
) -> crate::Result<PatchIntegrity> {
if manifest.version != PATCH_MANIFEST_VERSION {
return Err(PatchError::ApplyFailed(format!(
"unsupported patch manifest version {}",
manifest.version
)));
}
validate_patch_manifest(manifest)?;
let mut integrity = PatchIntegrity {
file_count: 0,
@@ -119,6 +313,403 @@ pub fn verify_patch_manifest_files(
Ok(integrity)
}
/// Validates manifest schema, paths, operation order and kind compatibility.
pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
if manifest.version != PATCH_MANIFEST_VERSION {
return Err(PatchError::ApplyFailed(format!(
"unsupported patch manifest version {}",
manifest.version
)));
}
for (label, value) in [
("patch_id", manifest.patch_id.as_str()),
("source_version", manifest.source_version.as_str()),
("target_version", manifest.target_version.as_str()),
] {
if value.is_empty()
|| value.contains('\0')
|| value.contains('/')
|| value.contains('\\')
|| value == "."
|| value == ".."
{
return Err(PatchError::ApplyFailed(format!(
"invalid patch manifest {label}: {value}"
)));
}
}
let mut paths = std::collections::BTreeSet::new();
for file in &manifest.files {
resolve_manifest_path(Path::new("."), &file.path)?;
if !paths.insert(file.path.clone()) {
return Err(PatchError::ApplyFailed(format!(
"patch manifest contains duplicate file path: {}",
file.path.display()
)));
}
let unity_operations = file
.operations
.iter()
.filter_map(unity_operation_target)
.collect::<Vec<_>>();
for (expected_sequence, operation) in file.operations.iter().enumerate() {
operation.payload.validate()?;
if operation.sequence != expected_sequence as u32 {
return Err(PatchError::ApplyFailed(format!(
"patch manifest operation order is not contiguous for {}: expected {}, got {}",
file.path.display(),
expected_sequence,
operation.sequence
)));
}
if let Some(archive_entry) = operation.archive_entry.as_deref() {
validate_archive_entry(archive_entry)?;
}
if operation.source_blake3.is_some() != operation.source_size.is_some() {
return Err(PatchError::ApplyFailed(format!(
"patch manifest operation source precondition must include hash and size: {} operation {}",
file.path.display(),
operation.sequence
)));
}
if file.operations.len() > 1
&& (operation.source_blake3.is_none() || operation.source_size.is_none())
{
return Err(PatchError::ApplyFailed(format!(
"multiple operations require source preconditions: {} operation {}",
file.path.display(),
operation.sequence
)));
}
if operation.archive_entry.is_some()
&& !matches!(
operation.payload,
PatchManifestOperationPayload::UnityFsTextAsset { .. }
| PatchManifestOperationPayload::UnityFsStringField { .. }
| PatchManifestOperationPayload::UnityFsField { .. }
)
{
return Err(PatchError::ApplyFailed(format!(
"archive_entry is only supported for UnityFS operations: {} operation {}",
file.path.display(),
operation.sequence
)));
}
if file.patch_kind != PatchKind::Mixed && file.patch_kind != operation.patch_kind() {
return Err(PatchError::ApplyFailed(format!(
"patch manifest kind mismatch for {} operation {}",
file.path.display(),
operation.sequence
)));
}
}
for (index, left) in unity_operations.iter().enumerate() {
for right in unity_operations.iter().skip(index + 1) {
if unity_operation_targets_conflict(left, right) {
return Err(PatchError::ApplyFailed(format!(
"patch manifest contains overlapping UnityFS targets in {}: {} and {}",
file.path.display(),
left.describe(),
right.describe()
)));
}
}
}
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
enum UnityOperationTarget<'a> {
WholeObject {
archive_entry: Option<&'a str>,
serialized_file_path: &'a str,
path_id: i64,
},
Field {
archive_entry: Option<&'a str>,
serialized_file_path: &'a str,
path_id: i64,
field_path: &'a str,
},
}
impl UnityOperationTarget<'_> {
fn archive_entry(&self) -> Option<&str> {
match self {
Self::WholeObject { archive_entry, .. } | Self::Field { archive_entry, .. } => {
*archive_entry
}
}
}
fn serialized_file_path(&self) -> &str {
match self {
Self::WholeObject {
serialized_file_path,
..
}
| Self::Field {
serialized_file_path,
..
} => serialized_file_path,
}
}
fn path_id(&self) -> i64 {
match self {
Self::WholeObject { path_id, .. } | Self::Field { path_id, .. } => *path_id,
}
}
fn describe(self) -> String {
match self {
Self::WholeObject {
archive_entry,
serialized_file_path,
path_id,
} => format!(
"archive={archive_entry:?}, serialized_file={serialized_file_path}, path_id={path_id}, object"
),
Self::Field {
archive_entry,
serialized_file_path,
path_id,
field_path,
} => format!(
"archive={archive_entry:?}, serialized_file={serialized_file_path}, path_id={path_id}, field={field_path}"
),
}
}
}
fn unity_operation_target(operation: &PatchManifestOperation) -> Option<UnityOperationTarget<'_>> {
let archive_entry = operation.archive_entry.as_deref();
match &operation.payload {
PatchManifestOperationPayload::UnityFsTextAsset {
serialized_file_path,
path_id,
..
} => Some(UnityOperationTarget::WholeObject {
archive_entry,
serialized_file_path,
path_id: *path_id,
}),
PatchManifestOperationPayload::UnityFsStringField {
serialized_file_path,
path_id,
field_path,
..
}
| PatchManifestOperationPayload::UnityFsField {
serialized_file_path,
path_id,
field_path,
..
} => Some(UnityOperationTarget::Field {
archive_entry,
serialized_file_path,
path_id: *path_id,
field_path,
}),
_ => None,
}
}
fn unity_operation_targets_conflict(
left: &UnityOperationTarget<'_>,
right: &UnityOperationTarget<'_>,
) -> bool {
if left.archive_entry() != right.archive_entry()
|| left.serialized_file_path() != right.serialized_file_path()
|| left.path_id() != right.path_id()
{
return false;
}
match (left, right) {
(UnityOperationTarget::WholeObject { .. }, _)
| (_, UnityOperationTarget::WholeObject { .. }) => true,
(
UnityOperationTarget::Field {
field_path: left_path,
..
},
UnityOperationTarget::Field {
field_path: right_path,
..
},
) => field_paths_overlap(left_path, right_path),
}
}
fn field_paths_overlap(left: &str, right: &str) -> bool {
left == right || is_field_path_parent(left, right) || is_field_path_parent(right, left)
}
fn is_field_path_parent(parent: &str, child: &str) -> bool {
child
.strip_prefix(parent)
.is_some_and(|suffix| suffix.starts_with('.') || suffix.starts_with('['))
}
/// Builds a manifest from release-root bytes and ordered operation payloads.
///
/// This function does not infer or apply operations. Callers construct target
/// bytes with the owning algorithm/adapter first, then this builder binds the
/// actual source and target hash/size to the auditable manifest. Operation
/// sequence numbers are assigned from the supplied vector order.
pub fn build_patch_manifest(
source_root: &Path,
target_root: &Path,
patch_id: impl Into<String>,
source_version: impl Into<String>,
target_version: impl Into<String>,
files: Vec<PatchManifestBuildFile>,
rollback: PatchRollback,
) -> crate::Result<PatchManifest> {
let mut manifest_files = Vec::with_capacity(files.len());
for file in files {
let source_path = resolve_manifest_path(source_root, &file.path)?;
let target_path = resolve_manifest_path(target_root, &file.path)?;
let source = read_manifest_file(&source_path, "source")?;
let target = read_manifest_file(&target_path, "target")?;
let has_unity_operation = file.operations.iter().any(|operation| {
matches!(
&operation.payload,
PatchManifestOperationPayload::UnityFsTextAsset { .. }
| PatchManifestOperationPayload::UnityFsStringField { .. }
| PatchManifestOperationPayload::UnityFsField { .. }
)
});
let has_archive_operation = file
.operations
.iter()
.any(|operation| operation.archive_entry.is_some());
if has_archive_operation && !has_unity_operation {
return Err(PatchError::ApplyFailed(format!(
"manifest builder archive operations must be UnityFS operations: {}",
file.path.display()
)));
}
let operation_count = file.operations.len();
let direct_operations = !has_unity_operation && !has_archive_operation;
let mut current = source.clone();
let operations = file
.operations
.into_iter()
.enumerate()
.map(|(sequence, mut operation)| {
operation.payload.validate()?;
if direct_operations && operation.source_blake3.is_none() && operation_count > 1 {
operation.source_blake3 = Some(blake3_hex(&current));
operation.source_size = Some(current.len() as u64);
} else if !direct_operations
&& operation_count > 1
&& operation.source_blake3.is_none()
{
return Err(PatchError::ApplyFailed(format!(
"manifest builder requires source preconditions for multiple non-direct operations: {}",
file.path.display()
)));
}
if direct_operations {
if let (Some(expected_hash), Some(expected_size)) =
(operation.source_blake3.as_deref(), operation.source_size)
{
if expected_hash != blake3_hex(&current)
|| expected_size != current.len() as u64
{
return Err(PatchError::ApplyFailed(format!(
"manifest builder operation source precondition mismatch: {} operation {}",
file.path.display(),
sequence
)));
}
}
current = apply_direct_payload(&current, &operation.payload)?;
} else if operation_count == 1 {
if let (Some(expected_hash), Some(expected_size)) =
(operation.source_blake3.as_deref(), operation.source_size)
{
if expected_hash != blake3_hex(&source)
|| expected_size != source.len() as u64
{
return Err(PatchError::ApplyFailed(format!(
"manifest builder operation source precondition mismatch: {} operation {}",
file.path.display(),
sequence
)));
}
}
}
/*
* Non-direct operations are produced by the owning adapter.
* Their source preconditions describe adapter-produced
* intermediate bytes, which this crate cannot reconstruct.
*/
operation.sequence = sequence as u32;
Ok(operation)
})
.collect::<crate::Result<Vec<_>>>()?;
if direct_operations && current != target {
return Err(PatchError::ApplyFailed(format!(
"manifest builder operations do not produce target bytes: {}",
file.path.display()
)));
}
manifest_files.push(PatchManifestFile {
path: file.path,
patch_kind: file.patch_kind,
source_blake3: blake3_hex(&source),
target_blake3: blake3_hex(&target),
source_size: source.len() as u64,
target_size: target.len() as u64,
operations,
});
}
let manifest = PatchManifest {
version: PATCH_MANIFEST_VERSION,
patch_id: patch_id.into(),
source_version: source_version.into(),
target_version: target_version.into(),
files: manifest_files,
rollback,
};
validate_patch_manifest(&manifest)?;
Ok(manifest)
}
fn apply_direct_payload(
source: &[u8],
payload: &PatchManifestOperationPayload,
) -> crate::Result<Vec<u8>> {
match payload {
PatchManifestOperationPayload::Binary { patch } => {
crate::binary::apply_binary_patch(source, patch)
}
PatchManifestOperationPayload::Json { patch } => {
let source = std::str::from_utf8(source).map_err(|error| {
PatchError::ApplyFailed(format!("JSON patch source is not UTF-8: {error}"))
})?;
let patch = serde_json::to_string(patch).map_err(|error| {
PatchError::ApplyFailed(format!("failed to serialize JSON patch: {error}"))
})?;
crate::json::apply_json_patch(source, &patch).map(|value| value.into_bytes())
}
PatchManifestOperationPayload::Text { patch } => {
let source = std::str::from_utf8(source).map_err(|error| {
PatchError::ApplyFailed(format!("text patch source is not UTF-8: {error}"))
})?;
crate::text::apply_text_patch(source, patch).map(|value| value.into_bytes())
}
PatchManifestOperationPayload::UnityFsTextAsset { .. }
| PatchManifestOperationPayload::UnityFsStringField { .. }
| PatchManifestOperationPayload::UnityFsField { .. } => Err(PatchError::ApplyFailed(
"manifest builder cannot apply UnityFS payload without bat-assetbundle".to_string(),
)),
}
}
/// Verifies one manifest file entry against source and target bytes.
pub fn verify_patch_file_bytes(
source: &[u8],
@@ -151,7 +742,14 @@ pub fn verify_patch_file_bytes(
}
fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf> {
if relative.is_absolute() {
if relative.is_absolute()
|| relative.as_os_str().is_empty()
|| relative.to_string_lossy().contains('\0')
|| relative.to_string_lossy().contains('\\')
|| relative == Path::new(".")
|| relative.components().count() == 0
|| relative.to_string_lossy().as_bytes().get(1) == Some(&b':')
{
return Err(PatchError::ApplyFailed(format!(
"patch manifest path must be relative: {}",
relative.display()
@@ -171,6 +769,26 @@ fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf>
Ok(root.join(relative))
}
fn validate_archive_entry(entry: &str) -> crate::Result<()> {
if entry.is_empty()
|| entry.contains('\0')
|| entry.contains('\\')
|| entry.as_bytes().get(1) == Some(&b':')
{
return Err(PatchError::ApplyFailed(format!(
"patch manifest archive entry is unsafe: {entry}"
)));
}
for component in Path::new(entry).components() {
if !matches!(component, Component::Normal(_) | Component::CurDir) {
return Err(PatchError::ApplyFailed(format!(
"patch manifest archive entry escapes archive root: {entry}"
)));
}
}
Ok(())
}
fn read_manifest_file(path: &Path, label: &str) -> crate::Result<Vec<u8>> {
fs::read(path).map_err(|error| {
PatchError::ApplyFailed(format!(
@@ -238,6 +856,269 @@ mod tests {
assert!(matches!(error, PatchError::ApplyFailed(_)));
}
#[test]
fn build_patch_manifest_binds_release_metadata_and_operation_order() {
let temp = tempfile::tempdir().unwrap();
let source_root = temp.path().join("source");
let target_root = temp.path().join("target");
fs::create_dir_all(&source_root).unwrap();
fs::create_dir_all(&target_root).unwrap();
let source = b"before";
let target = b"after";
fs::write(source_root.join("file.bin"), source).unwrap();
fs::write(target_root.join("file.bin"), target).unwrap();
let manifest = build_patch_manifest(
&source_root,
&target_root,
"localized-v1",
"official-v1",
"localized-v1",
vec![PatchManifestBuildFile {
path: PathBuf::from("file.bin"),
patch_kind: PatchKind::Binary,
operations: vec![PatchManifestOperation {
sequence: 99,
source_blake3: None,
source_size: None,
archive_entry: None,
payload: PatchManifestOperationPayload::Binary {
patch: crate::binary::diff(source, target),
},
provenance: None,
}],
}],
PatchRollback {
previous_current_target: None,
remove_target_path: None,
},
)
.unwrap();
assert_eq!(manifest.source_version, "official-v1");
assert_eq!(manifest.files[0].source_blake3, blake3_hex(source));
assert_eq!(manifest.files[0].target_blake3, blake3_hex(target));
assert_eq!(manifest.files[0].operations[0].sequence, 0);
}
#[test]
fn build_patch_manifest_records_each_direct_operation_source_precondition() {
let temp = tempfile::tempdir().unwrap();
let source_root = temp.path().join("source");
let target_root = temp.path().join("target");
fs::create_dir_all(&source_root).unwrap();
fs::create_dir_all(&target_root).unwrap();
let source = b"before";
let intermediate = b"middle";
let target = b"after";
fs::write(source_root.join("file.bin"), source).unwrap();
fs::write(target_root.join("file.bin"), target).unwrap();
let first = crate::binary::diff(source, intermediate);
let second = crate::binary::diff(intermediate, target);
let manifest = build_patch_manifest(
&source_root,
&target_root,
"localized-v1",
"official-v1",
"localized-v1",
vec![PatchManifestBuildFile {
path: PathBuf::from("file.bin"),
patch_kind: PatchKind::Binary,
operations: vec![
PatchManifestOperation {
sequence: 20,
source_blake3: None,
source_size: None,
archive_entry: None,
payload: PatchManifestOperationPayload::Binary { patch: first },
provenance: None,
},
PatchManifestOperation {
sequence: 21,
source_blake3: None,
source_size: None,
archive_entry: None,
payload: PatchManifestOperationPayload::Binary { patch: second },
provenance: None,
},
],
}],
PatchRollback {
previous_current_target: None,
remove_target_path: None,
},
)
.unwrap();
assert_eq!(
manifest.files[0].operations[0].source_blake3,
Some(blake3_hex(source))
);
assert_eq!(
manifest.files[0].operations[1].source_blake3,
Some(blake3_hex(intermediate))
);
}
#[test]
fn validate_patch_manifest_rejects_invalid_json_operation_payload() {
let mut manifest = manifest_for("file.json", b"{}", b"{\"value\":1}");
manifest.files[0].patch_kind = PatchKind::Json;
manifest.files[0].operations = vec![PatchManifestOperation {
sequence: 0,
source_blake3: None,
source_size: None,
archive_entry: None,
payload: PatchManifestOperationPayload::Json {
patch: serde_json::json!({"op": "replace", "path": "/value", "value": 1}),
},
provenance: None,
}];
assert!(validate_patch_manifest(&manifest).is_err());
}
#[test]
fn validate_patch_manifest_allows_sibling_fields_on_one_unity_object() {
let manifest = unity_manifest(vec![
unity_string_operation(None, "first", 0),
unity_field_operation(None, "second", 1),
]);
validate_patch_manifest(&manifest).unwrap();
}
#[test]
fn validate_patch_manifest_rejects_duplicate_unity_field() {
let manifest = unity_manifest(vec![
unity_string_operation(None, "first", 0),
unity_string_operation(None, "first", 1),
]);
assert!(validate_patch_manifest(&manifest).is_err());
}
#[test]
fn validate_patch_manifest_rejects_whole_object_and_field_overlap() {
let manifest = unity_manifest(vec![
unity_text_asset_operation(None, 0),
unity_string_operation(None, "first", 1),
]);
assert!(validate_patch_manifest(&manifest).is_err());
}
#[test]
fn validate_patch_manifest_rejects_parent_child_field_overlap() {
let manifest = unity_manifest(vec![
unity_string_operation(None, "root", 0),
unity_field_operation(None, "root.child", 1),
]);
assert!(validate_patch_manifest(&manifest).is_err());
}
#[test]
fn validate_patch_manifest_applies_the_same_identity_rules_inside_zip_entries() {
validate_patch_manifest(&unity_manifest(vec![
unity_string_operation(Some("bundles/one.bundle"), "first", 0),
unity_field_operation(Some("bundles/one.bundle"), "second", 1),
]))
.unwrap();
for operations in [
vec![
unity_string_operation(Some("bundles/one.bundle"), "first", 0),
unity_string_operation(Some("bundles/one.bundle"), "first", 1),
],
vec![
unity_text_asset_operation(Some("bundles/one.bundle"), 0),
unity_string_operation(Some("bundles/one.bundle"), "first", 1),
],
vec![
unity_string_operation(Some("bundles/one.bundle"), "root", 0),
unity_field_operation(Some("bundles/one.bundle"), "root.child", 1),
],
] {
assert!(validate_patch_manifest(&unity_manifest(operations)).is_err());
}
validate_patch_manifest(&unity_manifest(vec![
unity_string_operation(Some("bundles/one.bundle"), "first", 0),
unity_string_operation(Some("bundles/two.bundle"), "first", 1),
]))
.unwrap();
}
fn unity_manifest(operations: Vec<PatchManifestOperation>) -> PatchManifest {
let mut manifest = manifest_for("bundle", b"source", b"target");
manifest.files[0].patch_kind = PatchKind::Mixed;
manifest.files[0].operations = operations;
manifest
}
fn unity_string_operation(
archive_entry: Option<&str>,
field_path: &str,
sequence: u32,
) -> PatchManifestOperation {
PatchManifestOperation {
sequence,
source_blake3: Some(blake3_hex(b"source")),
source_size: Some(6),
archive_entry: archive_entry.map(str::to_string),
payload: PatchManifestOperationPayload::UnityFsStringField {
serialized_file_path: "CAB-one".to_string(),
path_id: 7,
field_path: field_path.to_string(),
expected_value: None,
replacement: "replacement".to_string(),
},
provenance: None,
}
}
fn unity_field_operation(
archive_entry: Option<&str>,
field_path: &str,
sequence: u32,
) -> PatchManifestOperation {
PatchManifestOperation {
sequence,
source_blake3: Some(blake3_hex(b"source")),
source_size: Some(6),
archive_entry: archive_entry.map(str::to_string),
payload: PatchManifestOperationPayload::UnityFsField {
serialized_file_path: "CAB-one".to_string(),
path_id: 7,
field_path: field_path.to_string(),
expected_value: None,
replacement: serde_json::json!({"kind": "string", "value": "replacement"}),
},
provenance: None,
}
}
fn unity_text_asset_operation(
archive_entry: Option<&str>,
sequence: u32,
) -> PatchManifestOperation {
PatchManifestOperation {
sequence,
source_blake3: Some(blake3_hex(b"source")),
source_size: Some(6),
archive_entry: archive_entry.map(str::to_string),
payload: PatchManifestOperationPayload::UnityFsTextAsset {
serialized_file_path: "CAB-one".to_string(),
path_id: 7,
expected_name: None,
replacement: b"replacement".to_vec(),
},
provenance: None,
}
}
fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest {
PatchManifest {
version: PATCH_MANIFEST_VERSION,
@@ -251,6 +1132,7 @@ mod tests {
target_blake3: blake3_hex(target),
source_size: source.len() as u64,
target_size: target.len() as u64,
operations: Vec::new(),
}],
rollback: PatchRollback {
previous_current_target: None,
+1 -1
View File
@@ -21,7 +21,7 @@ Go `cmd/bat-api` 是资源 bootstrap、已发布资源分发和鉴权控制服
- `/admin/` 与白名单 `/admin/control/{action}`;其中翻译管理面包含
`/admin/translation/tasks``/admin/translation/handoff`
`/admin/translation/memory/summary``/admin/translation/memory/query`
`translation-memory-confirm` 转发
`translation-memory-confirm``translation-memory-resolve-conflict` 转发
- `/openapi.yaml`
HTTP 路由的 OpenAPI 文本由 `internal/api/openapi.go` 提供,运行中的服务也可
+7 -4
View File
@@ -196,8 +196,10 @@ pub struct ParserRegistry {
### 4. 翻译系统(目标扩展,Go;当前 worker 由 Rust `bat` 承担)
当前已实现的是 Rust `bat` 的离线 TextUnit 队列、mock/Crowdin provider worker、
lease/retry、结果落库、项目级 Translation Memory V1 和独立 Glossary V1。TM 位于独立
SQLite,按 raw source + 完整 context 做 trusted exact reusecandidate 必须显式 confirm
lease/retry、结果落库、项目级 Translation Memory persistence schema V2 和独立 Glossary
domain/feature contract V1SQLite persistence schema V2)。TM 位于独立 SQLite,按 raw
source + 完整 context 做 current Trusted exact reusecandidate 必须显式 confirm
同一 identity 的不同译文必须显式 supersede,历史 Trusted 冲突必须显式 resolve
Glossary 只有 approved term 进入 provider/TM 自动流程,并在结果上执行确定性 QA;模糊
匹配和完整 Provider 体系仍属后续缺口。
@@ -229,7 +231,7 @@ type TranslationProvider interface {
- Azure Translator Provider
**翻译记忆库**
- 当前 V1raw source 完全相同、完整 context 完全相同且记录为 trusted 时自动复用。
- 当前规则raw source 完全相同、完整 context 完全相同且只有一条 current Trusted 时自动复用。
- provider 输出写入先是 candidatemanual task result 不会自动建立 TM 或 trusted。`bat i18n memory confirm` 显式确认单条记录后才可自动复用。
- source、context、release、TextUnit、provider 和 run provenance 保存在 Rust TM SQLite 中。
- 模糊匹配、术语优先级和 PostgreSQL 服务化仍不是当前实现。
@@ -296,7 +298,8 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
### 7. Web 后台 (Vue 3,目标设计)
当前只有 `bat-api` 内嵌 dashboard MVPRust `bat` 的 Glossary V1 已实现,登录、角色、Web 术语管理和完整协作审核仍未实现。
当前只有 `bat-api` 内嵌 dashboard MVPRust `bat` 的 Glossary domain/feature contract V1
及 SQLite persistence schema V2 已实现,登录、角色、Web 术语管理和完整协作审核仍未实现。
**技术栈**
- Vue 3 + Composition API
@@ -36,8 +36,10 @@
- 新的跨语言控制和查询能力优先增加 Rust RPC contract,再由
`internal/backendrpc` 消费。
4. **完整游戏业务 API、完整 Web 协作后台和 Provider 扩展体系仍是后续目标;Rust `bat` 已持有 Glossary V1Web 术语协作视图仍待建设**
Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。
4. **完整游戏业务 API、完整 Web 协作后台和 Provider 扩展体系仍是后续目标;Rust `bat` 已持有
Glossary domain/feature contract V1SQLite persistence schema V2),Web 术语协作视图仍待建设**;
Translation Memory persistence schema V2 已由 Rust `bat` 持有,不能从目标架构图推断 Go
侧拥有第二份状态。
---
+15 -13
View File
@@ -1,6 +1,6 @@
# AssetBundle 与资源解析路线图
- **更新时间**2026-09-04
- **更新时间**2026-09-12
- **适用范围**:Rust 解析引擎、官方同步后的解析缓存、CAS/ResourceRepository 接入、后续文本提取和 Patch 发布。
- **权威关联**`PROJECT_PLAN.md` Milestone 3/4/5/8`docs/reports/CURRENT_GAPS.md` G-005/G-007/G-011/G-011D。
- **开发状态**:解析扩展当前按路线图和真实回归继续推进。
@@ -29,10 +29,10 @@
| --- | --- | --- | --- |
| 官方 seed manifest | `TableCatalog.bytes``BundlePackingInfo.bytes``MediaCatalog.bytes` | 完整下载 URL、相对路径、hash 校验边界 | 已用于下载计划,仍需沉淀更多结构化字段 |
| Addressables catalog | `catalog_*.zip` 内 JSON/bin catalog、`catalog_*.hash` | asset path、provider、dependencies、size、CRC、bundle name | JSON/compact 当前目标字段已覆盖;未知结构返回明确错误 |
| UnityFS container | `.bundle`、zip 内 bundle | header、block、directory、解压文件、基础摘要 | 已支持基础解包、LZ4/LZMA、alignment、大小/计数/路径/边界校验 |
| UnityFS container | `.bundle`、zip 内 bundle | header、block、directory、解压文件、基础摘要 | 已支持基础解包、LZ4/LZMA、alignment、大小/计数/路径/边界校验;受支持 patch 可保留容器形态重建 |
| Serialized file | UnityFS directory 文件 | header、type table、TypeTree node、object table、TextAsset bytes | 已支持基础表结构和 TextAsset bytes |
| Unity 对象字段 | TextAsset、MonoBehaviour、ScriptableObject | 可翻译文本单元、上下文、资源定位 | TypeTree 基础字段读取、`SerializedReference` / prefixed managed-reference metadata alias、payload 提取和字符串提取已落地,真实结构覆盖继续扩大 |
| Patch 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | TextAsset、TypeTree string field 和 managed-reference string field 的 localized publish/rollback 已落地;整体 AssetBundle 重打包与通用 manifest 发布仍未完成 |
| Patch 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | generic manifest 已驱动 Binary/JSON/Text 与当前支持的 UnityFS 操作;可验证 ZIP 内 bundle 时会在外层重写后重新读取、重解析并校验定位字段/替换值 |
---
@@ -51,13 +51,15 @@
9. `TextUnitExtractor` 已把 JSON/CSV/TSV/plain TextAsset、TypeTree 字符串字段和 TypeTree-covered managed reference payload 字符串输出为可序列化 TextUnit/JSONLzip 场景保留 archive entryTextUnit 明细包含 serialized file、path id、class id、field path、字段 offset/byte size、format、asset name 和上下文。managed-reference 类型元数据保留为 payload context,不进入翻译文本队列;即使 registry 暂时只能走 fallback 字段遍历,`RefIds``className``namespaceName``asmName` 等元数据别名也会被跳过,payload/value/object 家族和 `managedReferenceData` / `referenceData` / `serializedData` 仍按 payload 处理,并按 `RefIds[n]` 等记录前缀或子字段推导 metadata,避免多条 fallback record 混用 managed-reference context。
10. `ResourceImportService` 能把 AssetBundle 摘要、TextAsset/Table/Media 分类和 TextUnit 摘要写入导入报告。
11. 官方同步后 `OfficialParseCacheService` 能从 `official-download-manifest.json` 遍历所有资源,解析直接 bundle 和 zip 内条目,非候选资源记录为 unsupported,并缓存 TextUnit 数量/格式/诊断摘要,同时写出 `official-textunit-index.json``parse.text_units` / `parse.errors` 查询。
12. `rebuild_unityfs_bundle` 保留当前已验证 bundle 的 header 版本、directory 顺序/路径/flags、block 数量/压缩模式、block-info-at-end 和 block-data alignment;变长目录文件会重新计算目录 offset、block size、block-info hash 和总大小,未知压缩模式明确拒绝。
13. 重建后会重解析并校验未修改 directory 文件、serialized object table、未修改 object raw bytes,以及目标对象的未修改 TypeTree 字段;不能证明保真的结构不会发布。
当前还不能宣称完整:
1. TypeTree-covered managed reference 字段和 registry 记录已可结构化解码并参与文本提取,常见 registry 命名别名(含 `m_ManagedReferences``RefIds``m_RefIds`、verbose type 字段)、metadata 命名别名(含 `id``typeInfo`)、payload 命名别名(含 `data``value``payload``object``managedReferencePayload``referencePayload``serializedReferencePayload``managedReferenceValue``referenceValue``serializedReferenceValue``managedReferenceObject``referenceObject``serializedReferenceObject``managedReferenceData``referenceData``serializedData`)、full typename 拆解和 payload-only TextUnit 提取已有回归覆盖,多记录 registry 聚合也已有单元回归;fallback 字段遍历会跳过常见 registry 元数据字符串,避免误入翻译队列,并按记录前缀或子字段可推导 metadata 保留 managed-reference TextUnit context。enum `value__` backing field 和 `LayerMask` / `BitField``m_Bits` backing field 已可语义化解码和替换;`Vector2f/3f/4f``Quaternionf``ColorRGBA``Rectf``AABB/Bounds/Ray``Matrix4x4f``Vector2Int/Vector3Int``RectInt``BoundsInt``RangeInt``GUID``Hash128` 等固定 Unity 值类型的 leaf 和 direct child TypeTree 形态已可结构化解码和语义替换;array/vector/staticvector/List/HashSet/map 元素与 registry payload 字段已保留独立 field path、offset 和 byte size,可用于字符串元素 patchmanaged-reference registry payload 字符串、enum、bit_field、unknown fixed-size raw bytes、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 已可整体变长替换,`first/second``key/value` map entry schema 已有 serialized 和 UnityFS 重建回归,ScriptableObject `key/value` map 解析、变长替换和 UnityFS 重建已有专门回归,且嵌套 vector `Array``List<T>` / `HashSet<T>` 集合 alias、enum、bit_field、unknown fixed-size raw bytes 与 managed-reference payload 字段已有重建回归覆盖;后续仍需继续补齐真实样本驱动的完整 managed reference registry / map entry 变体、unknown 字段结构语义和版本差异。
2. Addressables 当前目标 JSON/compact 字段链已补齐;未识别的独立二进制格式仍返回明确错误,不静默降级。
3. 官方 release 已可配置导入 CAS + ResourceRepository,并可通过 `resource.index` 查询现有资源索引;Resource metadata 已记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要。
4. 不能完成复杂对象字段重打包,也不能从真实 Crowdin 结果自动生成完整汉化文件集合。
4. 尚未覆盖所有真实 Unity 版本、未知字段语义和任意复杂 AssetBundle 结构,也不能从真实 Crowdin 结果自动生成完整汉化文件集合;当前仅对已有真实/合成回归覆盖的结构宣称支持
---
@@ -154,7 +156,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
1. 官方同步完成后可配置触发导入 CAS + ResourceRepository(已具备 `--import-repository` / `BAT_IMPORT_REPOSITORY=1`)。
2. ResourceRepository 已保存官方 manifest 资源的类型、路径、hash、size 和 metadatametadata 包含 release、平台、bundle path、parse status、TextAsset 名称、TextUnit 数量/格式。
3. 支持 RPC/CLI 查询资源、bundle、TextAsset、解析错误和缓存状态;当前 `resource.index` 会返回资源 metadata`parse-status` 会返回 TextUnit 索引和队列摘要,`parse-text-units` / `parse-errors` 会按当前 release 查询明细,`localized-status`校验 patch manifest。
3. 支持 RPC/CLI 查询资源、bundle、TextAsset、解析错误和缓存状态;当前 `resource.index` 会返回资源 metadata`parse-status` 会返回 TextUnit 索引和队列摘要,`parse-text-units` / `parse-errors` 会按当前 release 查询明细,`localized-status`区分 patch manifest contract 与 artifact integrity`release.status/list/distribution/cleanup` 提供 Rust-owned 双 release 视图和安全运维操作
4. schema 迁移可重复执行;当前 SQLite 已有 `crc``metadata_json` 兼容迁移。
验收:
@@ -169,17 +171,17 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
交付:
1. 已定义 `localized-patch-manifest.json`:目标官方版本、localized release、输出文件、hash、size、byte delta、TextUnit/provider/review trace 和回滚信息。
2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作。
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果。
1. 已定义并接入 generic `PatchManifest`:目标官方版本、localized release、输出文件、hash、size、按序 operation、算法载荷、UnityFS 定位、TextUnit/provider/review trace 和回滚信息。
2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作;普通 ZIP 条目在 `archive_entry` 可验证、内层可重解析时会解包、重建并重写外层 ZIP,路径穿越、symlink、混合直接/ZIP patch 和无效内层 bundle 明确失败
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果generic manifest 不把 UnityFS 定位信息扁平化
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`rollback 按 manifest 恢复上一 release。
5. 成功后发布状态从 `not_localized` 切到 `localized``localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release。
5. 成功后发布状态从 `not_localized` 切到 `localized``localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release,并分别报告 schema/contract 与 artifact integrityZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验;current 仍存在但产物损坏时返回 `localized.degraded`,不自动修复
验收:
1. Patch 失败不影响 `bat-resources/current`
2. 汉化 release 保留官方相对目录结构。
3. `localized` 状态能证明原版和汉化两套资源都已发布,且 patch manifest 可验证。
3. `localized` 状态能证明原版和汉化两套资源都已发布,且 generic/localized patch manifest 可验证。
---
@@ -208,7 +210,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
1. 继续补充 Addressables Windows/Android 真实 catalog 样本和独立二进制格式诊断。
2. 继续补充 TypeTree 字段 reader、MonoBehaviour/ScriptableObject 遍历和真实版本差异。
3. 基于 `translation.worker.run` 扩展 TM/Glossary 和通用 manifest Patch 构建
3. 基于 `translation.worker.run` 继续扩展 TM/Glossary provenance 和真实资源发布样本
4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引。
5. 在通用 Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包和通用
Patch 发布流程统一,保留当前受支持 localized patch 发布/rollback 链路
5. 在 generic Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包,但只在
新结构有真实 fixture 和完整重建验证时接入;不扩大当前 UnityFS V1 的宣称范围
+24 -14
View File
@@ -179,10 +179,10 @@
`resource.index` RPC / CLI 只读查询现有 SQLite 索引;索引不存在时返回
`available=false`,不会因为查询创建空库。发布后的 TextUnit 队列还会在当前
release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrations`
管理 queued/running/failed/completed/skipped、provider run、lease、失败分类、
重试计划和 TextUnit 级译文结果。跨 release 的 Translation Memory V1 独立存储在
`<output>/translation-memory.sqlite`,记录 raw source/hash、完整 context、candidate/
trusted 和 release/TextUnit/provider/run provenance`translation.tasks` 优先查询这份状态库,
管理 V2 queued/running/failed/completed/skipped、provider run、lease、失败分类、
重试计划和 TextUnit 级译文结果。跨 release 的 Translation Memory persistence schema V2
独立存储在 `<output>/translation-memory.sqlite`,记录 raw source/hash、完整 context、
candidate/trusted 和 release/TextUnit/provider/run provenance`translation.tasks` 优先查询这份状态库,
`translation.worker.run` 由 Rust worker 回写状态;`translation.task.update` 仍供外部 provider 流程回写状态;
没有状态库的旧 release 才回退到 immutable JSON 队列。`bat doctor cas`
已提供只读 CAS 根目录、对象目录、元数据库文件和对象统计诊断;`resource.index`
@@ -239,12 +239,12 @@ trusted 和 release/TextUnit/provider/run provenance`translation.tasks` 优
8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。
9. 远端变化、本地 audit 发现 repair_needed,首次空目录运行,或缺少 `current` 原子发布指针时,进入下载/发布流程。
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。新 staging 还会扫描已发布 release 的下载 manifest,按规范化 destination 查找候选并重新验证 size、BLAKE3 和 ZIP 结构;硬链接失败时回退到临时文件复制和原子 rename,历史 release 保持不可变。
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot,并在 staging 中写入 `official-launcher-bootstrap.json`(若本轮启用 `--auto-discover`)。
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后,在 staging 中写入独立的 `official-distribution-publication.json` 发布事实和新的 snapshot,并写入 `official-launcher-bootstrap.json`(若本轮启用 `--auto-discover`)。publication 文件关联 official release ID、完整 mapping identity、manifest content identity 和 entry count;缺少或不匹配时 release 不可作为 distribution-ready。
12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/current` symlink 指向该 versioned 目录。
13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json``crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。
14. 随后刷新 active release 下的 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。
15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。历史 release 候选失效时,已有 CAS 对象会先经过完整性和元数据校验,再增加 release 引用并原子物化;当前 release 在 `official-cas-reuse-references.json` 中记录引用staging/release 清理时递减,失败则回退网络并保留诊断。
16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;UnityFS TextAsset patch 发布成功并通过 `localized-patch-manifest.json`、current symlinkrelease ID 校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。
14. 随后刷新 active release 下的 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。历史 release 复用只允许不可变资源 payload/sidecar 硬链接;download manifest、snapshot、parse/textunit cache、queue、handoff、bootstrap、CAS reuse references 以及 `translation-tasks.sqlite`、WAL/SHM 都必须独立复制,不能共享可变 inode。
15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。历史 release 候选失效时,已有 CAS 对象会先经过完整性和元数据校验,再增加 release 引用并原子物化;当前 release 在 `official-cas-reuse-references.json` 中记录引用和首次生成后持久化的 `ownership_id`staging/release 清理时按 `ownership_id + ordinal` 递减。旧无 identity 清单按 output-root scope、稳定 source mapping identity 和 generation counter 迁移;已有 basename ledger 的部分 cleanup 继续使用兼容 key,直到该 generation 完成,后续同名 generation 不复用该 key,失败则回退网络并保留诊断。
16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;generic manifest 驱动的 Binary/JSON/Text 以及当前支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlinkrelease ID 及 ZIP 内层最终重解析校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`localized.status` 分开返回 `patch_manifest_contract_status``artifact_integrity_status`state/current/identity 存在但文件被截断或手工修改时返回 `localized.degraded`,只读检查不回滚、不删除、不修复。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。
维护期特殊分支:如果官方 launcher/server-info 已经指向新资源根,但 client-patch seed marker 或必需 seed catalog 仍返回 403/404 等未开放状态,`bat` 返回 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging;若本轮启用 `--auto-discover`,会在 `<output>/official-launcher-bootstrap.pending.json` 写入待处理 launcher bootstrap 证据,供后续排障和自研客户端开发使用。
@@ -345,9 +345,11 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
- 方法命名空间与实现状态、请求/响应示例见
`docs/reference/rpc-backend-api.md``daemon.status/logs/stop/restart/reload/refresh/doctor`
`resource.state/sync/verify/repair/manifest/list/index``parse.status/text_units/errors`
`translation.tasks/handoff/task.update/proofread``localized.status``catalog.*`
`task.status/list/cancel/logs` 已实现;文件级 `patch.apply` / `unityfs.patch_*`
已实现,发布级 patch 与复杂 UnityFS 语义编辑待引擎;
`translation.tasks/handoff/task.update/proofread``localized.status`
`release.status/list/distribution/cleanup``catalog.*` `task.status/list/cancel/logs`
已实现;文件级 `patch.apply` / `unityfs.patch_*`
和受支持 localized publish/rollback 已实现,`archive_entry` 可验证时会重写
外层 ZIP;通用发布级 patch 与复杂 UnityFS 语义编辑仍待后续;
`task.create` 按设计暂不开放通用任务入口;
`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径;
`daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
@@ -356,7 +358,7 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
- Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 是资源读侧、
bootstrap 和 HTTP 分发入口。二者之间的稳定边界是 `bat.sock` RPC 和
`resource_root` 中已发布的只读文件。
Rust 选择后返回的 `resource_root` 中已发布的只读文件。
- Go 层负责:资源 bootstrap、资源内容分发(`cmd/bat-api`)、HTTP API 进程配置、
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon(连接
`bat.sock`,每行一个 JSON-RPC 请求/响应)。`cmd/bat` 仍是试验骨架,不是产品级用户 CLI。
@@ -368,8 +370,16 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
- 只读提供 Rust `bat` 已发布 release 中的资源字节(官方 CDN host/path 形态)。
- CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再
`catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再读取
Rust 轻量 `release.attestation`,最后按 attested release/publication/mapping/manifest
identity 和 verification generation 读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket
文件)。Go 不重新实现 release verifier;普通 current CDN 只有在 attestation
fresh/ready、分页快照完整且本地只读路径检查都允许时才分发。
- `/v1/releases``/v1/distribution` 和受保护的 `/admin/releases/status`
`/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed
结果;localized 或历史分发不会绕过 Rust 完整性判断。
- 受保护的 `/admin/control/release-cleanup` 只转发 Rust `release.cleanup`
先 dry-run 获取 `plan_id`,执行时由 Rust 重验证引用、路径和 current 保护。
- 支持 `.env` / 环境变量配置监听端口、public base URL、RPC socket 和 RPC
刷新周期,并预留 database/redis 键供后续 API 持久化;**不**负责资源自动拉取。
- 可选改写 server-info 中的 `AddressablesCatalogUrlRoot` 指向自身;不伪装
+98 -13
View File
@@ -1,6 +1,6 @@
# 官方资源 Release 布局与资源侧契约
- **更新时间**2026-09-04
- **更新时间**2026-09-12
- **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `bat-api` 分发 path 的 1:1 对应关系。
- **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。
- **权威代码**
@@ -8,6 +8,7 @@
- inventory 抽取:`adapters/src/official/inventory.rs`
- 落盘与 manifest`infrastructure/src/official_download.rs``destination_for_url`
- 发布布局:`infrastructure/src/official_update.rs`
- 双 release 视图、分发选择与清理:`infrastructure/src/release_ops.rs`
- 分发:`cmd/bat-api` + `internal/api`(见 `docs/reports/GO_STATUS.md`
---
@@ -17,7 +18,7 @@
| 角色 | 组件 | 职责 |
|---|---|---|
| 同步 / 运维(近乎全自动) | Rust `bat` | auto-discover、拉取、校验、发布、watch/daemon、RPC 后端 |
| 资源 bootstrap / 只读分发 | Go `bat-api` | 同环境经 `bat.sock` 发现已发布版本和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写官方 CDN path 字节 |
| 资源 bootstrap / 只读分发 | Go `bat-api` | 同环境经 `bat.sock` 读取 Rust 选择的已验证版本和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写官方/localized CDN path 字节和 release 管理转发 |
| 试验 CLI | Go `cmd/bat``bin/bat-go` | 非产品;禁止与 Rust `bat` 重名 |
**禁止**:把已安装客户端目录或 `/home/wanye/D/BlueArchive` 当作生产输入;真实全量样本优先服务器 release 或 `/tmp` 隔离目录。
@@ -31,13 +32,14 @@
current -> versions/<id> # 原子 symlink,生产读侧
versions/<id>/ # 已发布 versioned release= resource_root
official-download-manifest.json
official-distribution-publication.json # 独立发布事实:release、mapping、manifest identity、entry count
official-parse-cache.json # 校验后派生解析缓存,不是汉化产物
official-textunit-index.json # TextUnit 明细与解析错误索引,不是汉化产物
official-textunit-tasks.json # 翻译任务候选派生队列,不发 Crowdin 网络请求
crowdin-textunit-queue.json # Crowdin worker 离线输入队列
official-sync-snapshot.json # 常在 active root / current 下
official-launcher-bootstrap.json # 官方 launcher 引导链版本化产物
official-cas-reuse-references.json # 当前 release 获取的 CAS 引用
official-cas-reuse-references.json # 当前 release 获取的 CAS 引用和 ownership_id
prod-clientpatch.bluearchiveyostar.com/
<root_token>/
TableBundles/
@@ -75,7 +77,12 @@
<localized-output>/ # 汉化产物发布根(--localized-output / BAT_LOCALIZED_OUTPUT
current -> versions/<id> # 已汉化后才切换;未汉化状态不发布
versions/<id>/ # 与官方相对路径一致的汉化资源
localized-version-state.json # 预留:后续 Patch 发布阶段维护,官方同步阶段不写入
localized-patch-manifest.json # localized wrapper + generic PatchManifest 审计输入/结果
localized-distribution-manifest.json # 实际 localized bytes/hash 的轻量分发索引
.staging/<id>/ # generic/translation patch 未发布写侧
localized-version-state.json # localized current、官方 source release 和 workflow 状态
.localized-release.lock # 跨进程单写者锁
.localized-transaction.json # publish/rollback 崩溃恢复日志
```
官方资源发布和汉化发布是两个独立状态:
@@ -83,13 +90,47 @@
- `not_localized`:官方原版资源已经完成下载、校验和发布,汉化资源尚未发布;这是官方同步完成后的默认状态。
- `localized`:同一官方版本的原版资源和汉化资源都已发布,生产侧可以同时提供两套资源。
### 2.1 读侧 vs 写侧
### 2.1 双 release 读写边界
Rust `bat``release.status` 是 official/localized 的统一重型只读视图,基于既有
version state、current symlink、release manifest、文件系统和必要的 CAS/reference
元数据计算,不建立第二个 release 数据库。`release.list` 返回两个 namespace 的当前
与历史 release,包含稳定 ID、created/published、source official relation、生命周期、
`rollback_available`、manifest contract、artifact/distribution integrity、
`stale`/`damaged`/`referenced`/`unknown`、rollback previous 和诊断;缺少 generic manifest 的
旧 localized release 保留为 `legacy`/`unknown`,不自动改写。
`release.distribution` 的默认 channel 是 `official`。只有当前或显式历史、路径归属安全、
source relation 正确且 manifest/artifact integrity 通过的 release 才能被选择;staging、
损坏、缺失、symlink/path escape 或未验证历史项不会回退到另一 channel。Rust 使用已发布
manifest 做轻量选择,HTTP 热路径不重新执行完整 release auditlocalized 必须额外满足
`localized-distribution-manifest.json` 与 source official manifest 的 destination/URL
集合及 deterministic source mapping identity 一致,并返回实际 localized bytes/hash。
manifest 同时保存 localized mapping identity 和 destination index;返回的 `resource_root` 和 manifest entry
由 Rust 决定,Go 只做 typed forwarding;传入 `destination` 时 Rust 会重新校验该文件的
实际 bytes/BLAKE3。单条请求只比较发布时持久化的 identity、通过 destination index
定位 entry,不重新遍历全量映射或资源文件。
localized publish/rollback 先取得 `.localized-release.lock`,并在 output root 下记录
`.localized-transaction.json`。current、version-state 和 version 目录的切换按日志阶段
推进;publish 只有最终 `verified` phase 才能 roll-forward,下一次写操作会先恢复或完成
未决事务,避免跨进程并发写入和中断后留下半发布状态。`release.cleanup execute` 先取得
同一 official `.official-sync.lock`,再按固定顺序取得 localized 锁并在持锁状态下重建
计划;dry-run 不占用 official mutation lock。
`release.cleanup` 先生成 dry-run 计划和 `plan_id`,执行时重新计算并比对计划。current、
rollback previous、active/in-progress、localized source official、state/manifest/CAS
reference、无法确认 ownership 的对象均保留;只删除重新验证后仍为普通目录且确定无引用的
历史 release。它不改变 current,不执行 rollback,也不负责自动 repairstaging 默认保留
以避免删除未持久化任务。
### 2.2 读侧 vs 写侧
| 阶段 | 根目录 |
|---|---|
| 下载写入 | `<output>/.staging/<id>` |
| 发布完成 | rename 到 `versions/<id>`,再切换 `current` |
| 生产读取 / bat-api | RPC 给出的 `version.resource_root`通常等价于 `current` 解析后的 versioned 目录 |
| 生产读取 / bat-api | RPC 给出的已验证 `resource_root`默认等价于 official `current` 解析后的 versioned 目录localized 必须显式选择 |
每个 release 的 `official-download-manifest.json` 是历史复用的索引。新 staging
按规范化 destination 查找候选,并重新验证 manifest 中的 size、BLAKE3 和 ZIP
@@ -97,9 +138,25 @@
跨文件系统时复制到 staging 内的临时文件并原子 rename,旧 `versions/<id>` 目录
保持不可变。
从 CAS 物化资源时,`official-cas-reuse-references.json` 记录每个获取的对象引用,
文件带版本字段且允许重复 object ID。孤儿 staging 或显式 release 清理必须先按
清单减少 CAS 引用,再删除目录;CAS 对象损坏、缺失或元数据不一致时只产生诊断,
新 official release 在完整下载、manifest、文件和 ZIP 校验完成后,才会在 versioned
目录中原子写入 `official-distribution-publication.json`。该文件独立记录
`official_release_id`、完整 distribution mapping identity、manifest content identity
manifest 文件 BLAKE3)和 `entry_count`。普通 manifest 读写、release status 查询和
`release.distribution` 不会重建或刷新它;如果 manifest 在发布后变化、publication
文件缺失或两者 identity 不一致,该 release 的 `distribution_integrity_status` 不是
`valid`,不能被 distribution 读侧选择。没有该文件的历史 release 仍可被状态/清理逻辑
识别为 `legacy`,但不会被当作 distribution-ready。
从 CAS 物化资源时,`official-cas-reuse-references.json` 首次创建时生成并持久化
`ownership_id`,记录每个获取的对象引用;文件带版本字段且允许重复 object ID。
没有 `ownership_id` 的旧清单首次 cleanup 按 output-root scope、release ID、稳定 source
mapping identity 和 generation counter 建立 persistent legacy generation identity
若已存在 basename ledger,则在该 generation 完成前继续使用 basename compatibility key
完成后同名新 generation 使用新的 ownership。`.cas-owner-scope` 是 output-root 私有状态,
不会复制到另一个 release 或 staging。
孤儿 staging 或显式 release 清理必须先按
清单减少 CAS 引用,再删除目录;cleanup execute 与官方同步共用
`.official-sync.lock`localized cleanup 使用 `.localized-release.lock`。CAS 对象损坏、缺失或元数据不一致时只产生诊断,
回退网络下载,不发布未经校验的文件。
---
@@ -136,7 +193,11 @@ GET {public-base-url}/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
≡ 磁盘 <resource_root>/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
```
默认仅服务 **download manifest 索引内且 Present + size 匹配** 的文件。
默认仅服务 **official download manifest 索引内且 Present + size 匹配** 的文件。需要
localized 或历史 release 时,调用 `release.distribution` 选择 Rust 已验证的
`resource_root`,再由 `/v1/distribution` 或带 `channel`/`release_id` 的 CDN path
转发;localized CDN 使用 Rust 返回的实际 bytes/hash 生成 ETag,并在显式请求时校验
实际文件长度;Go 不在本地判断健康度,也不回退到 official。
### 3.3 launcher 资源引导兼容
@@ -195,8 +256,28 @@ GET {public-base-url}/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
| `bytes` | 文件大小 |
| `blake3` | 本地 BLAKE3 hex |
manifest 还持久化以下 distribution 查询字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| `distribution_mapping_identity` | string | 完整 URL、destination、size、BLAKE3 映射的确定性 identity |
| `destination_index` | map destination → URL | 单 destination 分发的轻量定位索引 |
**权威清单**:拉取闭环写入的 manifest;`bat-api` / RPC `resource.manifest` 以此为应有集合,再以磁盘校验 Present。
`official-distribution-publication.json` 是发布事实而不是 manifest 派生缓存。单条
distribution 查询只读取该文件、当前 manifest 的内容 identity 和
`destination_index`,再校验目标文件的 size/BLAKE3;不会为了定位一个 destination
重做完整 mapping canonicalization 或遍历其他资源。
`official-distribution-attestation.json` 是 Rust full local verification 的结果。发布时
文件先写入 `.staging/<id>`,但其中的 `resource_root` 永远记录最终的
`versions/<id>` canonical root;随后 staging 目录原子重命名并切换 `current`,不会因为
重命名再次增加 verification generation。周期性 current 验证、显式 verify/repair 和新
release 发布都会写入新的 generation;失败会写入 `ready=false``integrity_status=invalid`
`verified_at=null` 的新结果。`max_age_seconds` 由 Rust watch 的验证周期和失败重试
周期计算,缺失或为 0 的旧结果直接视为不可用,不使用固定兼容 fallback。
---
## 5. 发现与 seed URL 规则(L2
@@ -309,9 +390,13 @@ Addressables 改写后客户端拼接:
1. `daemon.status`
2. `daemon.doctor`
3. `catalog.status``version.resource_root``addressables_root`、app/bundle
4. `resource.manifest` 分页(url / destination / bytes / blake3
5. `resource_root` 上 Lstat 校验 Present / size
3. `release.attestation`,消费 Rust 当前 official 的 `ready`、release/publication/
manifest identity、verification generation、freshness 和 integrity 事实
4. `catalog.status``version.resource_root``addressables_root`、app/bundle
5. `resource.manifest` 分页(请求携带 release/publication/manifest identity;响应每页
返回同一组 identity、generation、total、offset、limit
6. 在 `resource_root` 上 Lstat 校验 Present / size;该检查只验证 Go 读快照,
不替代 Rust release verifier
**不读** `bat-status.json` / `bat-tasks.json` 作为常规路径。
+15 -14
View File
@@ -23,29 +23,30 @@
## 2. 当前验证命令
必须通过:
提交前的只读统一门禁必须通过:
```bash
make test
make check
make lint
make ci-check
```
等价底层命令
该命令等价覆盖
```bash
cargo test --workspace
cargo check --workspace
cargo clippy --workspace --all-targets -- -D warnings
go test ./internal/api/... ./internal/backendrpc/...
go vet ./...
cargo fmt --all -- --check
cargo check --workspace --locked
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
go test ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
go build -o /tmp/bat-api ./cmd/bat-api
make check-docs
```
说明:
1. 默认 Go 测试只覆盖正式 `bat-api` 依赖的纯 Go 包:`internal/api``internal/backendrpc``make test-go-ffi` / `make test-go-all` 才会包含 FFI 和试验 CLI。
2. `make check` 当前直接执行 `go vet ./...`,因此会检查所有已存在的 Go 包;新增 Go 产品 package 后,必须同时纳入默认测试门禁
3. `golangci-lint` 当前仍是可选补充门禁;Go 的硬性验证是默认 API 测试、全量 `go vet``bat-api` 构建
2. `golangci-lint 2.12.2` 是 required gate;版本由 `scripts/ci-versions.sh` 固定,工具缺失或版本不匹配直接失败
3. `make format` / `make fmt` 会修改工作树;`make ci-check``make check``make test``make lint` 不应格式化源码
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`
如果构建环境的默认 Go cache 不可写,可将 `GOCACHE` 指向工作区外的临时目录,例如
@@ -89,8 +90,8 @@ git check-ignore -v Cargo.lock CLAUDE.md AGENTS.md CONTRIBUTING.md
当前开发优先推进:
1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。
2. 基于 `translation.worker.run` 继续扩展 TM/Glossary,并推进通用 manifest Patch 构建
3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
2. 基于 `translation.worker.run` 继续扩展 TM/Glossary,并补充复杂 AssetBundle 的真实 fixture 与发布验证
3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和 generic manifest 发布所需资源视图。
4. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
5. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
+83 -10
View File
@@ -34,6 +34,42 @@ bat res pull --auto-discover --watch --interval 1h \
资源下载默认使用 8 个独立 worker,允许范围为 `1..=256`。worker 完成当前 URL 后立即领取共享队列中的下一个任务,进度按完成顺序统计,最终报告仍按计划顺序输出。
## Release 查询与清理
双 release 运维由 Rust `bat` 通过 `bat.sock` 提供,不新增平行顶层 CLI
```bash
# 查看 official/localized 当前、历史、source relation 和完整性
printf '{"jsonrpc":"2.0","id":1,"method":"release.status"}\n' \
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
printf '{"jsonrpc":"2.0","id":2,"method":"release.list","params":{"channel":"localized"}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
# 选择已验证的 localized 当前 release,默认 channel 仍是 official
printf '{"jsonrpc":"2.0","id":3,"method":"release.distribution","params":{"channel":"localized"}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
```
`release.distribution` 只返回 Rust 已验证的当前或显式历史 release。localized 必须
同时满足 source official、current、manifest identity、source/target hash/size 和
UnityFS/ZIP 最终语义校验;staging、损坏、缺失或路径不安全的 release 不会跨 channel
fallback。`localized.status``patch_manifest_contract_status`
`artifact_integrity_status` 分开表示 schema 和产物健康度,产物损坏时为
`localized.degraded`,检查不会自动修复。
清理必须先 dry-run,再使用同一 `plan_id` 执行:
```bash
printf '{"jsonrpc":"2.0","id":4,"method":"release.cleanup","params":{"execute":false}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
printf '{"jsonrpc":"2.0","id":5,"method":"release.cleanup","params":{"execute":true,"plan_id":"<plan-id>"}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
```
Rust 会在执行前重算计划,保护 current、rollback previous、active/in-progress、
localized source official、state/manifest/CAS/reference 和未知归属对象。cleanup 不改变
current、不执行 rollback,也不删除 staging;回滚仍使用独立的 `localized.rollback`
## 解析与重打包
解析当前已发布 release
@@ -68,7 +104,7 @@ bat parse clear-cache \
bat parse repack --repack-spec /tmp/bat-repack.json
```
重打包写入独立的 `target_bundle`,逐个操作后由底层 UnityFS patch 实现重建并校验,不允许 source 和 target 相同。
重打包写入独立的 `target_bundle`,逐个操作后由底层 UnityFS patch 实现重建并校验,不允许 source 和 target 相同。重建会保留已识别的 block 压缩、alignment、目录和未修改对象内容;未知压缩模式或无法证明保真的结构会失败。
## 翻译工作台与发布
@@ -89,7 +125,23 @@ bat i18n set \
--translated-text '中文文本'
```
也可以使用 `--translated-file` 读取 UTF-8 文本。需要复核单条内容时:
也可以使用 `--translated-file` 读取 UTF-8 文本。
如果译文触发 blocking Glossary QA,需先从 diagnose/任务结果取得当前
`qa_identity`,并与 reviewer、reason、provenance 一起提交;系统不会根据 workbench
中旧的 QA 自动补填:
```bash
bat i18n set \
--translation-file /tmp/bat-workbench.json \
--translation-id <text-unit-id> \
--translated-text '人工确认的译文' \
--glossary-qa-identity <qa-identity> \
--glossary-reviewer operator \
--glossary-reason '人工确认术语偏离' \
--glossary-provenance workbench
```
需要复核单条内容时:
```bash
bat i18n get \
@@ -161,12 +213,14 @@ bat i18n worker run \
`--watch`,因此可以单次、限定次数或周期执行;`--run-count > 1` 时仍必须
显式指定 `--interval`
Glossary V1 Rust `bat` 持有的独立项目级 SQLite 资产,默认位于
`<output>/glossary.sqlite`也可以用 `--glossary-path`
Glossary domain/feature contract V1 Rust `bat` 持有,并由 SQLite persistence schema V2
承载,默认位于 `<output>/glossary.sqlite`V2 正式吸收历史上的 `glossary_term_deletions`
schema drift;也可以用 `--glossary-path`
`BAT_GLOSSARY_PATH``[translation.worker].glossary_path` 指定。worker 只把
`approved` term 转成 provider-neutral constraints,并在 TM 复用、provider 返回
和人工工作台/任务回写时执行相同的确定性 QA。冲突或不符合推荐/允许译法的结果会
阻止自动完成;必须提交带 reviewer、reason 和 provenance 的显式 override。
阻止自动完成;必须提交带当前 `qa_identity`reviewer、reason 和 provenance 的显式
override。
常用 Glossary 操作:
@@ -303,7 +357,9 @@ Rust `translation.tasks` / `translation.handoff`,不在 Go 侧维护状态。
`translation.task.update`。人工校对流程提交译文时必须使用 `status=completed`
并为每个 `translation_results[]` 提供 `unit_id``source_text`
`translated_text`Rust 会用当前 `official-textunit-index.json` 校验 unit、
source text、destination 和 archive entry 后再落库。
source text、destination 和 archive entry 后再落库。blocking Glossary QA 还必须提交
与当前 QA 完全相等的 `glossary_override.qa_identity`;旧或缺少 identity 的 override
不会授权。
`POST /admin/control/translation-worker-run` 会触发 Rust 侧
`translation.worker.run`,请求字段为 `provider``fixture_path`
@@ -317,11 +373,13 @@ source text、destination 和 archive entry 后再落库。
## localized patch 发布与回滚
`i18n publish` 会在独立的 `.staging/<localized-release-id>` 中复制当前官方
release,校验工作台与当前 TextUnit 索引的 source/location 一致后,写入已有支持
release,校验工作台或 generic patch manifest 与当前官方 release 的 source identity
一致后,按确定的 operation sequence 写入 Binary、JSON、UTF-8 Text,以及已有支持
范围内的 TextAsset、TypeTree string field 和 managed-reference string field
patch。校验通过后才原子切换 `localized/current`,并在 release manifest 中记录
源/目标 BLAKE3、字节数、patch kind、TextUnit、provider、review 和 rollback
信息。ZIP 内 bundle 不会被静默改写。
源/目标 BLAKE3、字节数、patch kind、TextUnit、provider、review、发布时重新计算的
Glossary QA/override 和 rollback 信息。若 TextUnit 带有 `archive_entry`,发布会在 staging 内验证 ZIP 条目路径,修改并重解析内层 UnityFS 后重写外层 ZIP;路径不安全、内层结构无效或重打包工具失败时不会发布不完整结果。可通过
`--unzip <PATH>``--zip <PATH>` 或对应的 `BAT_UNZIP``BAT_ZIP` 配置工具路径。
使用人工编辑的工作台发布:
@@ -339,6 +397,21 @@ bat i18n publish \
--localized-release-id release-worker-1
```
已有 generic manifest 时直接发布:
```bash
bat i18n publish \
--patch-manifest /tmp/bat-patch-manifest.json \
--localized-release-id localized-v1
```
manifest 必须声明当前官方 `source_version` 和目标 localized `target_version`
文件 hash/size、操作顺序和算法载荷;直接文件支持 Binary/JSON/TextUnityFS 操作
必须保留 serialized file、path ID、field path 或 TextAsset 定位,ZIP 内 UnityFS
还必须保留 `archive_entry`。Rust 会在 staging 中逐操作验证 source precondition、
最终 hash/size,并在 ZIP 外层重写后重新读取条目、重解析 UnityFS 和校验实际替换值。
其他未支持的 UnityFS 结构仍明确拒绝。
发布失败会清理 staging,不切换 `current`。当前 release 的 rollback 目标由
manifest 记录,执行后删除本次版本目录并恢复上一版本;没有上一版本时移除
`current`
@@ -350,7 +423,7 @@ bat i18n rollback --localized-release-id release-worker-1
Rust RPC 方法为 `localized.publish``localized.rollback`bat-api 对应为
`POST /admin/control/localized-publish``POST /admin/control/localized-rollback`
以及鉴权的 `GET /admin/translation/status`。发布请求必须且只能包含
`translation_file``from_worker=true`rollback 可省略 release ID 以操作当前
`translation_file``from_worker=true``patch_manifest`rollback 可省略 release ID 以操作当前
release。Go 只做鉴权、参数校验和转发,状态与产物仍由 Rust 持有。
## 边界
+3 -3
View File
@@ -9,8 +9,8 @@ BlueArchive Toolkit 的部署文档分为当前可用模式和目标模式:
2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch` 或 RPC/daemon 模式。
3. **bat-api 资源 bootstrap / 分发服务**:当前可用,和 Rust `bat` 在同一服务器/容器环境运行,经 `bat.sock` RPC 获取当前 `resource_root`
4. **可选数据库开发环境**PostgreSQL/Redis 只服务于未来的 Go 服务层、完整 Web 协作后台和
Provider 扩展,不是当前 `bat` / `bat-api` 的生产运行依赖;当前 Translation Memory V1
使用 `<output>/translation-memory.sqlite`
Provider 扩展,不是当前 `bat` / `bat-api` 的生产运行依赖;当前 Translation Memory
persistence schema V2 使用 `<output>/translation-memory.sqlite`
5. **完整单机/分布式部署**:尚未提供。完整游戏业务 API、数据库迁移和 Web 未实现前,不把它作为可执行部署方案。
---
@@ -439,7 +439,7 @@ BAT_API_TRUST_PROXY_HEADERS=true
否则保持默认 `false``bat-api` 会按 TCP peer IP 做限流和日志归因。应用层访问日志只记录 path,不记录 query string,避免 query token 进入日志。动态 JSON 响应使用 `Cache-Control: no-store`;CDN 字节路径仍使用长期 immutable 缓存。
不要在生产 env 里设置 `BAT_API_RESOURCE_ROOT``bat-api` 会按 `BAT_API_REFRESH_INTERVAL` 周期通过 RPC 重新读取 `catalog.status` / `resource.manifest`,从而跟随 Rust `bat` 切换 `current -> versions/<id>`
不要在生产 env 里设置 `BAT_API_RESOURCE_ROOT``bat-api` 会按 `BAT_API_REFRESH_INTERVAL` 周期通过 RPC 读取当前 official `release.attestation`,再以同一 release/publication/mapping/manifest identity 和 verification generation 请求 `resource.manifest`,从而跟随 Rust `bat` 切换 `current -> versions/<id>`attestation 过期、generation 变化或分页不一致时 fail closed
### 健康检查
+20 -18
View File
@@ -23,9 +23,11 @@ rustc --version # 验证安装
cargo --version
```
#### 自托管 Gitea runner
#### 本地完整质量门禁
`.gitea/workflows/bat.yml` 使用 `runs-on: linux`,并且不依赖 `actions/checkout``dtolnay/rust-toolchain` 等外部 GitHub Action。runner 需要在执行环境中预装以下命令:
项目以本地 `make ci-check` 作为唯一完整 required quality gate。开发过程中可运行 focused
checks 以快速反馈,但提交前完整 gate 不得省略;仓库不依赖 Gitea、GitHub Actions 或其它
远端 CI runner。执行环境需要预装以下命令:
```bash
git --version
@@ -34,9 +36,13 @@ cargo --version
rustfmt --version
cargo clippy --version
go version
golangci-lint --version # 必须为 2.12.2
```
该 workflow 会用 `GITHUB_SERVER_URL``GITHUB_REPOSITORY``GITHUB_REF``GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的格式化、检查、构建、clippy 和测试,以及 Go API 门禁和文档状态门禁。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。
缺少上述命令、版本不匹配或 `golangci-lint` 不是 2.12.2 都会使 required gate 失败;
`golangci-lint 2.12.2` 是 required gate,不是可选检查。`make ci-check` 会执行 Rust
workspace 的只读格式检查、检查、release build、clippy 和测试,以及通过
`make check-go-format` 执行的 Go 格式、测试、vet、构建、2.12.2 lint 和文档状态门禁。
#### Docker
```bash
@@ -66,14 +72,11 @@ git checkout -b feature/your-feature-name
### 2. 开发
```bash
# 实时编译检查
make check
# 运行只读 required 门禁(不会格式化或修改源码)
make ci-check
# 运行测试
make test
# 格式化代码
make fmt
# 需要格式化时才修改工作树
make format
```
开发约束:
@@ -143,20 +146,19 @@ UnityFS / AssetBundle / Addressables / TypeTree 解析当前按路线图继续
### 合并前通用门禁
```bash
cargo fmt --all -- --check
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
make test-go-api
make build-go-api
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
make check-docs
make ci-check
```
`make ci-check` 是只读门禁入口;`make format` / `make fmt` 才会修改源码。
required 的 `golangci-lint 2.12.2``scripts/ci-versions.sh` 固定,缺失或版本不匹配
都会失败,不会伪报全部门禁通过。
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
- **同步/运维命令行** = Rust `bat`(近乎全自动)
- **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api``make build-go-api`
- **默认 Go 门禁** = `make test-go-api`(无 FFI
- **默认 Go 门禁** = `make ci-check` 中的纯 Go API test/vet/build 和
`golangci-lint 2.12.2`(无 FFI
- 试验 CLI 产物为 `bin/bat-go``make build-go-cli`),**禁止**与 Rust `bat` 重名
- 修改 FFI 时再跑 `make test-go-ffi`
+5 -4
View File
@@ -185,7 +185,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
- 新下载先写 `.part`,成功并通过必要校验后再替换为最终文件;如果断点续传后的 `.zip` 结构校验失败,会删除 `.part` 并重新全量下载
- 如果上一轮非 dry-run 已进入 staging 但未发布成功,下一轮会优先查找 `<output>/official-version-state.json` 中同一 app version、bundle version 和 Addressables root 的失败版本;只有对应 `<output>/.staging/<id>` 仍存在、路径安全且 `versions/<id>` 尚未发布时,才复用该 staging,并继续按 manifest 校验复用或重下单个 URL
- 新 release 的 staging 在访问网络前会扫描已发布 release 的 `official-download-manifest.json`。候选必须同时满足 manifest 记录的 destination、size、BLAKE3 和适用的 ZIP 结构校验;URL、CDN 根和 release ID 的变化本身不会阻止复用。命中后优先用硬链接,跨文件系统时回退为临时文件复制并原子 rename,旧 release 不会被修改
- 历史 release 候选失效时,如果配置的 CAS 根已有对应 BLAKE3 对象,会先通过 CAS 读取完整性和元数据,再增加当前 release 的引用并原子物化;当前 release 会写 `official-cas-reuse-references.json`,清理孤儿 staging 或显式清理 release 时递减这些引用。CAS 损坏、缺对象或元数据不一致会写入复用诊断并继续走网络下载,不会静默使用缓存
- 历史 release 候选失效时,如果配置的 CAS 根已有对应 BLAKE3 对象,会先通过 CAS 读取完整性和元数据,再增加当前 release 的引用并原子物化;当前 release 会写带持久化 `ownership_id` `official-cas-reuse-references.json`,清理孤儿 staging 或显式清理 release 时按 ownership 和 ordinal 递减这些引用;旧无 identity 清单保留 legacy cleanup key。CAS 损坏、缺对象或元数据不一致会写入复用诊断并继续走网络下载,不会静默使用缓存
- 把结果发布到 `--output/current`
## 5. 自动更新检查
@@ -228,8 +228,9 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
- `<output>/official-launcher-bootstrap.pending.json`:官方 launcher/server-info 已前进但 client-patch seed marker 或必需 seed catalog 尚未开放时写入的待处理 bootstrap 证据;它不代表资源已发布,也不会改变 `current`
- `<output>/official-bootstrap-cache.json``--auto-discover``GameMainConfig` 解析缓存。launcher metadata 与 remote manifest 文件列表 digest 都未变时复用缓存;任一变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
- `<output>/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、sizeBLAKE3。
- `<output>/current/official-cas-reuse-references.json`:当前 release 获取的 CAS 引用清单;每个复用项占一条记录,release 清理或孤儿 staging GC 时据此递减引用
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、sizeBLAKE3、deterministic distribution mapping identity 和 destination index
- `<output>/current/official-distribution-publication.json`:新 official release 完整校验后写入的独立发布事实,记录 official release ID、mapping identity、manifest content identity 和 entry count;它缺失或与 manifest 不一致时 release 不可分发。普通查询不会自动补写该文件
- `<output>/current/official-cas-reuse-references.json`:当前 release 获取的 CAS 引用清单,首次创建时包含持久化 `ownership_id`;每个复用项占一条记录,release 清理或孤儿 staging GC 时据此按 ownership/ordinal 递减引用。旧无 identity 清单使用 output-root 的 `.cas-owner-scope` 完成 generation-aware legacy cleanup;该文件不会被复制到新 release。
- `<output>/current/official-resource-changes.json`:当前 release 相对上一完整 release 的资源差异,记录新增、变更、删除以及解析/翻译候选计数。
- `<output>/current/crowdin-translation-handoff.json`:为后续 Crowdin worker 预留的本地队列,只包含新增+变更资源;它不是 Crowdin API 调用结果。
- `<output>/current/official-parse-cache.json`:官方资源发布后的派生解析缓存,记录 bundle/zip 条目解析摘要和缓存复用情况;它不是汉化产物。
@@ -326,7 +327,7 @@ cargo run -p bat-infrastructure --bin bat -- \
默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`watch/daemon 模式还会在每天北京时间(UTC+8)`03:00``16:00``18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,worker 从共享队列独立领取任务并在完成后立即领取下一项,完成计数保持单调不倒退,最终 report 的 `items` 仍按 pull plan 顺序排列,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON errorwatch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75``verify``doctor` 发现问题也返回非 0。
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。下载默认并发 8,可用 `--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`worker 动态领取共享 plan,finished 进度即时按完成数统计,发布 report 仍按 plan 顺序。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产官方资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取原版资源时应读取 `--output/current`,不要读取 `.staging``versions` 中未切换的目录。汉化 Patch/导出应写入 `--localized-output`,并保留官方相对目录结构,不能写回 `--output/current`。发布状态分两档:`not_localized` 只发布原版资源、不发布汉化资源;`localized` 发布原版和汉化两套资源。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一官方资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。下载默认并发 8,可用 `--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`worker 动态领取共享 plan,finished 进度即时按完成数统计,发布 report 仍按 plan 顺序。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产官方资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取原版资源时应读取 `--output/current`,不要读取 `.staging``versions` 中未切换的目录。汉化 Patch/导出应写入 `--localized-output`,并保留官方相对目录结构,不能写回 `--output/current`。发布状态分两档:`not_localized` 只发布原版资源、不发布汉化资源;`localized` 发布原版和汉化两套资源。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一官方资源目录;`release.cleanup` execute 使用同一个锁并在锁内重新生成/校验 `plan_id`localized cleanup 使用 `.localized-release.lock`live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
需要只做探测时可以加 `--dry-run`。需要关闭本地 audit 或 repair 时可以显式使用 `--no-audit-local``--no-repair`,但生产同步默认应保持开启。
+91 -19
View File
@@ -104,8 +104,8 @@ contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
| `resource.manifest` | 已实现 | `{ "offset": 0, "limit": 100 }` | 当前 download manifest 分页。 |
| `resource.list` | 已实现 | `{ "offset": 0, "limit": 100 }` | `resource.manifest` 的兼容别名。 |
| `resource.manifest` | 已实现 | `{ "release_id": "...", "expected_publication_identity": "...", "expected_manifest_identity": "...", "expected_verification_generation": 7, "offset": 0, "limit": 100 }` | 绑定一个 Rust attested official generation 的 download manifest 分页;generation 为必需绑定条件,`0` 也不能省略或忽略。 |
| `resource.list` | 已实现 | `resource.manifest` | `resource.manifest` 的兼容别名。 |
| `resource.index` | 已实现 | `{ "offset": 0, "limit": 100, "type": "asset_bundle", "hash": "...", "path_pattern": "*", "release_id": "...", "platform": "windows", "destination": "...", "archive_entry": "...", "parse_status": "parsed", "format": "json" }` | 当前 `ResourceRepository` 分页/过滤查询。 |
`resource.repair` 会开启本地 manifest audit + repair,不继承 `force`
@@ -119,6 +119,16 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
`1..=1000`,非法参数返回 `BAT-ERR-700002`
`resource.manifest` 的请求必须携带由 `release.attestation` 返回的
`release_id``expected_publication_identity``expected_manifest_identity`
`expected_verification_generation`
每一页返回 `release_id``resource_root``manifest_version`
`publication_identity``mapping_identity``manifest_identity``generation`
`total_entries``offset``limit``entries`。Rust 在当前 release 切换或 identity
不匹配、attestation 不可用或 generation 改变时拒绝请求;Go 会逐页验证 channel、
这些 identity、generation、manifest version、页 offset/limit、total 和最终 entry
count,任何一页不一致都会丢弃整个候选快照。
`resource.index``entries[]``Resource` JSON,除 `id``local_path`
`entry` 外会包含 `metadata``official_release_id``platform`
`bundle_path``archive_entries``parse_statuses``unity_versions`
@@ -155,23 +165,54 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
- `translation-tasks.sqlite`:当前 release 的可变 worker 状态库,记录
queued / running / failed / completed / skipped、attempt count、provider run
ID、provider、TextUnit 级译文结果、lease、失败分类、可重试标记和
next attemptschema 由 `schema_migrations` 版本表管理。
next attempt当前 schema version 为 V2schema 由 `schema_migrations` 版本表
管理,并通过只读 fingerprint、`BEGIN IMMEDIATE` 和显式 V1 → V2 migration
保证 future/未知 schema fail closed。
- `translation-handoff.json`:当前 release 的版本化 job/unit/provider run 交接
快照;worker 更新后的实时状态仍以 `translation-tasks.sqlite` 为准。
- `translation-memory.sqlite`:跨 release 的项目级 Translation Memory,不位于
`versions/<id>`,也不与 `translation-tasks.sqlite` 共用;记录 raw source/hash、完整
TextUnit context、candidate/trusted、translation 和 release/TextUnit/provider/run
provenance。默认路径为 `<output>/translation-memory.sqlite`,可由
provenance;当前 schema version 为 V1,打开时先进行只读 fingerprint preflight
再在 writer transaction 内补齐 `schema_migrations` 版本记录。默认路径为
`<output>/translation-memory.sqlite`,可由
`BAT_TRANSLATION_MEMORY_PATH``[translation.worker].translation_memory_path` 或 CLI
覆盖。
- `glossary.sqlite`:跨 release 的项目级 Glossary,不位于 `versions/<id>`,也不与
`translation-tasks.sqlite` 或 TM 共用;记录 term、alias、推荐/允许译法、scope、
priority、review 状态、source provenance完整 source/review history。默认路径为
priority、review 状态、source provenance完整 source/review history 和 deletion
audit;当前 schema version 为 V2。V2 正式吸收历史上未升版本的
`glossary_term_deletions` drift,打开时通过 fingerprint 和 writer transaction
区分 V1-A/V1-B 并 fail closed;正式 schema evolution 不再通过 `ensure_column`
隐式修复。
默认路径为
`<output>/glossary.sqlite`,可由 `BAT_GLOSSARY_PATH``[translation.worker].glossary_path`
或 CLI 覆盖。
删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。
### release
| 方法 | 状态 | params | data |
|---|---|---|---|
| `release.attestation` | 已实现 | `null` | 当前 official 的轻量 health/publication proof`available``ready``channel``release_id``resource_root``publication_identity``mapping_identity``manifest_identity``entry_count``integrity_status``status``status_code``verification_generation``verified_at``max_age_seconds` 和 diagnostics。只读取 current、publication anchor、manifest 元数据与 freshness,不扫描历史 release 或计算资源文件 BLAKE3。 |
| `release.status` | 已实现 | `null` | official/localized current、source relation、match、历史 release 和 manifest/artifact/distribution integrity 的重型管理诊断,仍返回管理侧 `official_distribution_ready`;普通 current bat-api readiness 使用 `release.attestation`。 |
| `release.list` | 已实现 | `{ "channel": "official" }``{ "channel": "localized" }`,可省略 | 对应 namespace 的历史 release 摘要,包含 stable ID、created/published、current pointer、`rollback_available`、lifecycle、`stale`/`damaged`/`referenced`/`unknown`、legacy 和诊断。 |
| `release.distribution` | 已实现 | `{ "channel": "official", "release_id": "...", "destination": "...", "offset": 0, "limit": 1000 }`,均可省略 | Rust 只选择具有独立 `official-distribution-publication.json` 且 identity 与当前 manifest 一致的 verified `resource_root`;有 `destination` 时是 single-entry lookup,响应固定 `total=1, offset=0, limit=1, entries.length=1`,使用 published mapping identity/destination index,只校验该实际文件的 bytes/BLAKE3,不重新执行全量映射或资源 audit;无 `destination` 时保留管理查询分页语义。localized 还必须匹配 source official 的 published identity,并使用发布时生成的实际字节 metadata,不复用 official size/hash;默认 channel 为 official,选择失败返回 `available=false`,不跨 channel fallback。 |
| `release.cleanup` | 已实现 | dry-run `{ "execute": false }`;执行 `{ "execute": true, "plan_id": "..." }` | cleanup plan、candidate/retain reasons、blocking references 和 removed paths;执行前会重新生成并比对 `plan_id`。 |
`release.status``release.list``release.distribution` 只读现有 official/localized
state、current、manifest、文件系统和 CAS/reference 元数据,不创建第二套 release 状态。
分发选择使用发布后的轻量 manifest 和文件 size/单文件 BLAKE3 校验,不在 HTTP 热路径
重新执行完整 release auditlocalized 还要求 distribution manifest 的 destination/URL
集合与 source official manifest 一致,并使用发布时记录的实际 localized bytes/hash。
publication 文件缺失、manifest content identity 变化或 source mapping identity 不一致时,
即使单个目标文件本身完整,也返回 `available=false`;旧 release 可被列出并标记
`legacy`,但不会被 distribution 读侧自动重建 publication metadata。默认官方分发行为不变。
`release.cleanup` 只删除 Rust 能证明是普通目录且未被 current、rollback、staging、
source、state、manifest、CAS 或未知 ownership 引用的历史项,不修改 current,也不承担
rollback 或 repair。
### schedule
调度计划由 Rust `bat` 持有,状态文件为 daemon `state_dir` 下的
@@ -220,9 +261,11 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
| `translation.task.update` | 已实现 | `{ "task_id": "...", "status": "failed", "failure_reason": "...", "provider_run_id": "..." }` | 写入当前 release 的 provider worker 状态,返回可回查任务记录。 |
| `translation.worker.run` | 已实现 | provider worker 参数 | 异步触发 Rust provider worker,返回 `{ "task_id": "...", "kind": "translation.worker.run", "worker": {...} }`。 |
| `translation.proofread` | 已实现 | `null` | 将当前汉化 workflow 标记为人工校对中,返回工作流状态报告。 |
| `translation.memory.summary` | 已实现 | 可选 `{ "translation_memory_path": "..." }` | 返回 TM schema 版本、总记录数candidate/trusted/rejected/superseded 状态计数。 |
| `translation.memory.query` | 已实现 | `{ "source_text": "...", "source_context": {...}, "limit": 100 }` | 按 raw source 查询记录,返回 match kind、trust、translation 和 provenance。 |
| `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | 显式确认一条 candidate 为 trustedworker 之后才可自动复用。 |
| `translation.memory.summary` | 已实现 | 可选 `{ "translation_memory_path": "..." }` | 返回 TM persistence schema 版本、总记录数candidate/trusted/rejected/superseded 状态计数和 trusted 冲突组计数。 |
| `translation.memory.query` | 已实现 | `{ "source_text": "...", "source_context": {...}, "limit": 100 }` | 按 raw source 查询记录,返回 match kind、trust、translation 和 provenanceconflict 结果不可自动复用。 |
| `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "...", "supersede_record_id": "..." }` | 显式确认 candidate 为 trusted已有不同 current Trusted 时必须显式 supersedeworker 之后才可自动复用。 |
| `translation.memory.conflicts` | 已实现 | 可选 `{ "translation_memory_path": "...", "limit": 100 }` | 只读列出 exact source/context 下存在多个 current Trusted 的冲突组。 |
| `translation.memory.resolve_conflict` | 已实现 | `{ "winner_record_id": "...", "expected_trusted_record_ids": ["..."], "reviewer": "...", "reason": "..." }` | 使用稳定 record ID 原子解决历史 Trusted 冲突,保留 supersede 历史并写入 audit event。 |
| `translation.glossary.summary` | 已实现 | 可选 `{ "glossary_path": "..." }` | 返回 Glossary schema 版本和 draft/approved/deprecated/rejected 计数;缺库只返回 `available=false`,不会创建空库。 |
| `translation.glossary.query` | 已实现 | `{ "source_text": "...", "category": "...", "review_status": "approved", "limit": 100 }` | 查询 term、alias、scope、source provenance 和完整 source/review history。 |
| `translation.glossary.diagnose` | 已实现 | `{ "source_text": "...", "context": {...} }` | 只对 approved term 生成 provider-neutral constraints,并返回冲突/覆盖诊断和 blocked 决策。 |
@@ -287,8 +330,10 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化
`status=completed` 时额外提交 `provider``provider_run_id`
`translation_results[]`,每个结果必须包含 `unit_id``source_text`
`translated_text`;结果也可以提交完整的 `glossary_override``reviewer`
`reason``provenance``confirmed_unix_seconds`),用于人工确认 Glossary
blocking deviation。Rust 会用当前 `official-textunit-index.json` 校验 unit、
`reason``provenance``confirmed_unix_seconds` 和当前 blocking QA 的
`qa_identity`),用于人工确认 Glossary blocking deviation。`qa_identity` 必须与
Rust 重新计算的当前 QA 完全相等;缺失或过期的 override 不授权。Rust 会用当前
`official-textunit-index.json` 校验 unit、
source text、destination 和 archive entry 后再落库。因此 worker 或人工校对流程
消费 handoff 后,bat-api 可通过 `translation.tasks` 查询单项任务,也可通过
`translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff`
@@ -326,14 +371,16 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t
复用前、provider 返回后、人工 `translation.task.update` 和 workbench publish 前执行
同一套确定性 QA;冲突或未使用推荐/允许译法的结果不会自动完成或发布。允许但非推荐译法
产生 warningblocking deviation 必须在对应结果中提交 `glossary_override`,并包含
`reviewer``reason``provenance` 和确认时间。系统不会在译文生成后做静默字符串替换。
`qa_identity``reviewer``reason``provenance` 和确认时间。Glossary 定义变化会
只使受影响 QA 的旧 override 失效;无关术语变化不会改变该 QA identity。系统不会在
译文生成后做静默字符串替换。
### localized
| 方法 | 状态 | params | data |
|---|---|---|---|
| `localized.status` | 已实现 | `null` | 汉化发布状态、当前官方 release 匹配关系和汉化输出目录。 |
| `localized.publish` | 已实现 | `{ "translation_file": "...", "localized_release_id": "...", "force": false }``{ "from_worker": true, "localized_release_id": "...", "force": false }` | 已校验并发布的汉化 release、manifest 和完整性报告。 |
| `localized.publish` | 已实现 | `{ "translation_file": "...", "localized_release_id": "...", "force": false }``{ "from_worker": true, "localized_release_id": "...", "force": false }``{ "patch_manifest": "...", "localized_release_id": "...", "force": false }`;三者只能选一个 | 已校验并发布的汉化 release、generic/localized manifest 和完整性报告。 |
| `localized.rollback` | 已实现 | `{ "localized_release_id": "..." }`,可省略 | 删除当前 release、恢复 manifest 记录的上一 release 和新状态。 |
`localized.status` 严格按 daemon / `config.toml` 或环境变量中的 `BAT_LOCALIZED_OUTPUT`
@@ -341,7 +388,7 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t
`./bat-localized` 混用。当前支持未汉化发布状态和已汉化发布状态的只读报告。
`status` / `status_code` 使用生命周期短状态和稳定状态码,例如
`pending` / `localized.pending``stale` / `localized.stale``published` /
`localized.published`;旧的 `localized` / `not_localized` 业务标签放在
`localized.published``localized.degraded`;旧的 `localized` / `not_localized` 业务标签放在
`localized_release_status``translation_workflow_status` / `translation_workflow_status_code`
用于表示汉化工作流的人工校对状态,例如 `manual_proofreading` /
`translation.manual_proofreading`。返回 `localized_release_status=localized` 的条件是:
@@ -349,8 +396,22 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t
`current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的
`localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回
`patch_manifest_path``patch_manifest_available`
`patch_manifest_matches_release``patch_file_count`
`patch_manifest_matches_release``patch_manifest_contract_status`
`patch_manifest_integrity_status`(兼容别名)、`artifact_integrity_status`
`artifact_integrity_verified``artifact_integrity_error`
`artifact_integrity_diagnostics`
`patch_manifest_source_version``patch_manifest_target_version`
`patch_file_count``patch_operation_count``patch_kind_counts`
`patch_text_asset_operation_count``rollback_previous_current_target`
每个 localized patch operation 的 manifest metadata 记录发布时重新计算的
`glossary_qa`(包括 `qa_identity`)及对应 `glossary_override`,不会复用 workbench
中已经过期的 QA 快照。
`localized.publish` 也可直接接收由 Rust `bat-patch` 构建的 generic manifest。
Rust 会把其 source version 绑定当前官方 release,在独立 staging 中按 manifest
顺序执行 Binary、JSON、UTF-8 Text 和当前支持的 UnityFS TextAsset/TypeTree 字段
操作,并保留实际操作载荷、hash/size、定位信息和 TextUnit/TM/Glossary/review
provenance。Go 只做鉴权、typed 参数校验和 RPC 转发。
### catalog
@@ -435,6 +496,12 @@ daemon 重启后仍处于 `queued` 或 `running` 的历史任务会被标记为
`data` 会返回 source / patch 或 replacement / target 的 size 与 BLAKE3。`target_path`
不能与输入文件相同。
`localized.publish` 从当前官方 TextUnit/工作台生成受支持的 UnityFS patch
operation;当 TextUnit 带有 `archive_entry` 时,发布 manifest 的 operation
会记录该可选字段,Rust 发布器会在独立 staging 中校验、重建内层 UnityFS 并
重写外层 ZIP。ZIP 路径、内层解析或重打包校验失败时整个发布失败,不会只发布
部分结果。
仍关闭的范围:通用 manifest 驱动的发布级 `patch build` / `patch rollback`、复杂 UnityFS 语义编辑、
`unityfs.inspect`、通用 manifest 驱动 release 切换。调用这些规划方法仍返回
`BAT-ERR-700003`
@@ -470,7 +537,8 @@ CLI 对应关系:
| `bat i18n worker run` | `translation.worker.run` |
| `bat i18n proofread` | `translation.proofread` |
| `bat i18n memory summary` / `bat i18n memory query` | `translation.memory.summary` / `translation.memory.query` |
| `bat i18n memory confirm` | `translation.memory.confirm` |
| `bat i18n memory confirm` / `bat i18n memory conflicts` | `translation.memory.confirm` / `translation.memory.conflicts` |
| `bat i18n memory resolve-conflict` | `translation.memory.resolve_conflict` |
| `bat i18n glossary summary` / `bat i18n glossary query` | `translation.glossary.summary` / `translation.glossary.query` |
| `bat i18n glossary diagnose` | `translation.glossary.diagnose` |
| `bat i18n glossary add/update` | `translation.glossary.add` / `translation.glossary.update` |
@@ -504,11 +572,12 @@ CLI 对应关系:
envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。
- typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、
`resource.state/sync/verify/repair/manifest/list``schedule.list/add/update/remove/run`
`catalog.*``parse.*`
`catalog.*``parse.*``release.status/list/distribution/cleanup`
`localized.status``localized.publish``localized.rollback`
`translation.tasks``translation.handoff``translation.task.update`
`translation.worker.run``translation.proofread``translation.memory.summary`
`translation.memory.query``translation.memory.confirm``translation.glossary.summary`
`translation.memory.query``translation.memory.confirm``translation.memory.conflicts`
`translation.memory.resolve_conflict``translation.glossary.summary`
`translation.glossary.query``translation.glossary.diagnose``translation.glossary.add`
`translation.glossary.update``translation.glossary.approve``translation.glossary.deprecate`
`translation.glossary.delete`
@@ -520,16 +589,19 @@ CLI 对应关系:
| 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` | 启动发现、周期刷新和资源分发基础数据 |
| `AttestationBackend` | `release.attestation` | current official 轻量 health/publication proof;不触发历史 release 扫描 |
| `ReleaseStatusBackend` | `release.status` | 鉴权管理面的 official/localized 重型 release 诊断;Go 不重新实现 verifier |
| `ControlBackend` | `daemon.restart``daemon.reload``daemon.refresh``resource.sync``resource.verify``resource.repair``catalog.refresh` | 鉴权后的管理控制白名单 |
| `ScheduleBackend` | `schedule.list``schedule.add``schedule.update``schedule.remove``schedule.run` | 鉴权后的 dashboard 调度计划控制 |
| `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 |
| `TaskBackend` | `task.list``task.status``task.logs``task.cancel` | 鉴权后的 daemon 任务查询和取消 |
| `ParseBackend` | `parse.status``parse.text_units``parse.errors` | 鉴权后的当前 release 解析状态、TextUnit 和解析错误只读查询 |
| `TranslationBackend` | `translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 |
| `TranslationMemoryBackend` | `translation.memory.summary``translation.memory.query``translation.memory.confirm` | 鉴权后的 TM 摘要、source/context 查询和显式 candidate 确认Go 只转发,不持有 TM 状态 |
| `TranslationMemoryBackend` | `translation.memory.summary``translation.memory.query``translation.memory.confirm``translation.memory.conflicts``translation.memory.resolve_conflict` | 鉴权后的 TM 摘要、source/context 查询和 Trusted 冲突治理Go 只转发,不持有 TM 状态 |
| `GlossaryBackend` | `translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` | 鉴权后的 Glossary 摘要、term/history 查询、确定性诊断和审核/删除 mutationGo 只转发,不持有 Glossary 状态 |
| `LocalizedBackend` | `localized.status``localized.publish``localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 |
| `ReleaseBackend` | `release.status``release.list``release.distribution``release.cleanup` | 鉴权后的双 release 查询、验证分发选择和 dry-run/execute cleanupGo 不持有 release 状态 |
`daemon.stop``daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
@@ -74,7 +74,8 @@ Rust 窗口请基于当前真实代码生成或导出以下 JSON:
2. `catalog.status` available=false 响应。
3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。
4. 对应 release 的 `official-sync-snapshot.json`
5. Rust Glossary V1 的 `translation.glossary.query` 响应,至少包含 alias、approved
5. Rust Glossary domain/feature contract V1、SQLite persistence schema V2 的
`translation.glossary.query` 响应,至少包含 alias、approved
review、source provenance 和 created/approved history。
输出应来自 Rust 代码路径,而不是手写 JSON。允许使用 fixture resource root 或临时目录,但不能依赖开发机真实资源目录。
+88 -28
View File
@@ -1,6 +1,6 @@
# 当前实现缺口清单
- **更新时间**2026-09-04
- **更新时间**2026-09-13
- **文档角色**:只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态。
- **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。
- **Go 进度**`GO_STATUS.md`
@@ -10,40 +10,47 @@
## 1. 当前工程缺口
### G-005AssetBundle 复杂解析仍未完成
### G-005AssetBundle V1 已完成,完整兼容仍未完成
状态:**部分完成,继续推进**
状态:**V1 已完成(仅限已验证结构),继续推进真实版本和复杂结构**
当前已具备 UnityFS 容器校验、directory 文件提取、serialized file
object/type table/TypeTree 元数据、TextAsset、基础 MonoBehaviour 和
ScriptableObject 字段读取、TextUnit 提取,以及受支持字段的文件级重建。
ScriptableObject 字段读取、TextUnit 提取,以及受支持字段的文件级
parse→modify→rebuild→reparse。重建会保留已识别的 block 压缩、alignment、
directory 形态和未修改对象/字段,并明确拒绝未知压缩或无法证明保真的输入。
仍需完成:
- 用真实资源 fixture 覆盖更多 MonoBehaviour、ScriptableObject、Unity 版本差异、
复杂容器和 managed reference registry/map entry 变体。
- 为未知字段补充结构语义;不能把低保真猜测当作已支持格式。
- 完成发布级复杂对象重打包,并把 bundle、serialized file、path id、class id、
field path、offset 和 byte size 的定位信息贯通到稳定发布流程
- 扩大真实 Unity 版本、复杂容器、未知字段和 managed-reference/map 变体覆盖;
当前 V1 不等价于任意 AssetBundle 结构的通用重打包
现有证据:`crates/bat-assetbundle` 的单元/重建测试、隔离真实 UnityFS 回归和
`bat-infrastructure` 的解析缓存测试。新增格式覆盖必须同时补真实 fixture、回归测试
和文档。
现有证据:`crates/bat-assetbundle` 的单元/压缩/对齐/变长重建测试、隔离真实 UnityFS
回归和 `bat-infrastructure` 的解析缓存、ZIP 内 bundle 发布测试。新增格式覆盖必须
同时补真实 fixture、回归测试和文档。
### G-006:通用 Patch 发布仍未完成
### G-006:通用 Patch 的复杂格式和运维扩展仍未完成
状态:**基础完成,发布流程部分完成**
状态:**V1 已完成(当前支持类型),复杂格式和运维扩展继续推进**
`bat-patch` 已提供 Binary/JSON/Text Patch、manifest、BLAKE3/size 校验和
rollback 元数据;文件级 `patch.apply` 与受支持的 UnityFS TextAsset、TypeTree
string field、managed-reference string field 写入及 localized publish/rollback
已可用。
`bat-patch` 已提供 Binary/JSON/Text Patch、manifest builder、BLAKE3/size 校验和
rollback 元数据;`LocalizedPatchService` 已使用同一有序 generic manifest 驱动
Binary/JSON/Text 与当前支持的 UnityFS TextAsset、TypeTree string/semantic field
写入及 localized publish/rollback。发布会在独立 staging 中校验 source/target identity、
逐操作 precondition、ZIP 内层重解析和实际字段替换,并保留 TextUnit/TM/Glossary/review
provenanceUnityFS 目标身份至少包含 archive entry、serialized file、path ID 和实际
field path,同对象 sibling field 可并存,重复、父子、whole-object/field 结构重叠会拒绝;
`localized.publish``i18n publish` 和 bat-api typed forwarding 均已接入。
仍需完成:
- 通用 manifest 驱动的跨类型 patch build/apply/publish/rollback。
- 复杂 AssetBundle 重打包和完整翻译文件集合构建
- 原版 release 与 localized release 双发布后的查询、分发和清理策略。
- 任意复杂 AssetBundle 重打包和完整翻译文件集合构建;当前 localized publish 已支持
可验证 ZIP 内 bundle 的外层 ZIP 重写,但不扩大 UnityFS 结构支持范围
- generic manifest 已冻结为当前支持类型的 V1;复杂 AssetBundle 结构仍需真实样本驱动,
不在本项中扩展 Patch 格式。
所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变
已发布的 `bat-resources/current``bat-localized/current`
@@ -68,6 +75,12 @@ provider、bundle name、resource type 和 CRC,并有 fixture/golden 回归。
launcher 资源引导兼容、只读 CDN path、readiness、OpenAPI、鉴权管理入口和内嵌
dashboard;翻译任务和 Rust-owned TM 的 summary/query/confirm 也通过 typed RPC
转发。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。
普通 current release 的 readiness、bootstrap、release summary 和 CDN 共用
Rust `release.attestation` 是 current official 的轻量 health/publication proof,带
release/publication/manifest identity、verification generation、freshness 和诊断;Go
只建立绑定同一代际的 manifest 读快照,不复制 Rust verifier。`release.status` 仍保留
为重型管理诊断。`make ci-check` 是只读统一门禁,required `golangci-lint 2.12.2`
缺失或版本不匹配直接失败。
仍需完成:
@@ -102,22 +115,68 @@ format 等资源级过滤,`parse.text_units` / `parse.errors` 和翻译任务
- 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。
- 更多 schema 迁移、权限、并发和损坏恢复场景验证。
### G-011D:双 release 的完整查询与发布策略仍未完成
### G-011D:双 release 查询、分发与安全清理
状态:**受支持范围完成,通用范围部分完成**
状态:**V1 已完成**
官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和
rollback`localized.status` 能校验当前官方 release 与 patch manifest 的一致性。
rollback。Rust `release.status` 提供统一 current/source/matchmanifest contract、
artifact/distribution integrity、历史 release、legacy/stale/damaged 摘要;
`release.list` 查询两个 namespace`release.distribution` 只允许当前或显式历史且已
验证的 official/localized release,默认仍为 official`release.cleanup` 提供 dry-run
`plan_id` 和执行前重验证,只删除确定未被 current、rollback、staging、source、状态、
manifest、CAS 或未知归属引用的普通目录。`localized.status` 还区分 schema/contract 与
artifact integrity,损坏产物返回 degraded/corrupt,不自动回滚或删除。
仍需完成通用 patch 发布、复杂重打包、双 release 查询/分发视图和清理策略。
rollback 与 cleanup 保持独立;缺少 generic manifest 的旧 localized release 仍可读,
明确标记 `legacy`/`unknown`,不会被自动重写。
### G-012Translation Memory V1 已实现,扩展能力仍缺失
本轮 P1 一致性修复已完成:CAS repository 的 store/get/reference/GC 使用跨进程操作锁,
release-local CAS 引用通过持久化 `ownership_id + ordinal` ledger 幂等释放;没有
`ownership_id` 的旧清单按持久化 output-root scope、source mapping 和 generation identity
迁移,已有 basename ledger 的部分 cleanup 保持 legacy compatibility key,完成后同名新
generation 使用新的 ownership,不随机迁移已开始的 cleanup。官方历史复用只对不可变文件使用 hard link,`translation-tasks.sqlite` 及 WAL/SHM 始终独立复制;
localized output 使用单写者锁和事务日志恢复 publish/rollbackpublish 只有最终
`verified` phase 才能 roll-forward,并在发布时写入实际 localized bytes/BLAKE3 的
distribution manifest。official/localized distribution manifest 还持久化 deterministic
source/localized mapping identity 和 destination index`release.distribution(destination=...)`
是单条 lookup,返回 exactly one entrysource identity mismatch 会阻断 localized
distribution,完整 identity 校验只在 publish/status/audit 路径执行;分发读取保留 path
ownership、symlink 和文件完整性检查;`release.cleanup execute` 与 official sync 共用
`.official-sync.lock`。新 official release 还持久化独立的
`official-distribution-publication.json`,把 release ID、完整 mapping identity、manifest
content identity 和 entry count 绑定到发布事实;publication 缺失或 manifest 变化时
official/localized distribution 均被阻断,普通查询不会自动重建。legacy release 仍可
列出和清理,但不视为 distribution-ready。本轮已关闭两个 Release/CAS P2。其他 P2
尚未由本轮处理:ResourceRepository
更完整的查询/权限/损坏恢复、模糊 TM、bat.sock peer credential/perms、FFI 生命周期、
资源大小/限额与更强的持久化 fsync 语义仍按后续专项推进。
Rust `bat` 已提供独立项目级 SQLite TM,记录 raw source/hash、完整 context、release/TextUnit/provider/run provenance,区分 candidate/trusted,只有显式 confirm 才能建立 trusted 记录;worker 只自动复用 trusted 的 raw source + 完整 context exact match,并在复用前执行已批准 Glossary 的确定性 QA。Go `bat-api` 已提供鉴权的 summary/query 只读接口和 confirm 转发,但 Go 不持有 TM 状态。仍缺少模糊匹配和更丰富的导入导出历史能力。
### G-012Translation Memory persistence schema V2 已实现,扩展能力仍缺失
### G-013Glossary V1 已实现,协作视图仍缺失
Rust `bat` 已提供独立项目级 SQLite TM,当前 persistence schema version 为 V2schema 打开遵守
只读 preflight、fingerprint、transaction rollback 和 future/unknown fail-closed
契约。它记录 raw source/hash、完整 context、release/TextUnit/provider/run provenance
区分 candidate/trusted,只有显式 confirm 或 conflict resolve 才能建立唯一 current
trusted 记录;worker 只自动复用 raw source + 完整 context exact match 的 current
trusted,并在复用前执行已批准 Glossary 的确定性 QA。Go `bat-api` 已提供鉴权的
summary/query/conflicts 只读接口和 confirm/resolve_conflict 转发,但 Go 不持有 TM 状态。
仍缺少模糊匹配和更丰富的导入导出历史能力。
Rust `bat` 已提供独立项目级 `glossary.sqlite`term/alias/recommended/allowed/category/priority、全局与 TextUnit scope、source history、approved review、冲突诊断、provider-neutral constraints 和确定性 QA 均由 Rust 持有。trusted TM 复用会先经过 Glossary QAprovider、TM、人工 task/workbench 结果都记录 QAblocking deviation 必须显式提交 reviewer/reason/provenance。`translation.glossary.*` 已通过 `bat.sock` 暴露,Go 仅提供鉴权后的 typed forwarding。剩余缺口是完整 Web 术语协作视图和更丰富的导入/搜索能力。
### G-013Glossary domain/feature contract V1、persistence schema V2 已实现,协作视图仍缺失
Rust `bat` 已提供独立项目级 `glossary.sqlite`,当前 schema version 为 V2V2 正式
吸收历史上未升版本的 `glossary_term_deletions` drift,并将历史 V1-A(无 deletion
audit)和 V1-B(已有 deletion audit)分别纳入显式迁移。打开遵守只读 fingerprint
preflight、writer transaction、rollback 和 future/unknown fail-closed 契约,不再
使用隐式 `ensure_column` 修复结构。term/alias/recommended/
allowed/category/priority、全局与 TextUnit scope、source history、approved review、
冲突诊断、provider-neutral constraints 和确定性 QA 均由 Rust 持有。trusted TM 复用会
先经过 Glossary QAprovider、TM、人工 task/workbench 结果都记录 QAblocking
deviation 必须显式提交与当前 QA 精确绑定的 `qa_identity` 及 reviewer/reason/provenance。
localized publish 会把发布时重算的 QA 写入 manifest。`translation.glossary.*` 已通过
`bat.sock` 暴露,Go 仅提供鉴权后的 typed forwarding。剩余缺口是完整 Web 术语协作视图
和更丰富的导入/搜索能力。
### G-014:完整 Provider 扩展体系未实现
@@ -143,8 +202,9 @@ Rust `bat` 已提供独立项目级 `glossary.sqlite`term/alias/recommended/a
## 3. 后续推进顺序
1. 继续 G-005:真实 AssetBundle 样本、复杂字段解析和发布级重打包。
2. 继续 G-006/G-011D:通用 manifest Patch 和双 release 查询/清理策略。
1. 继续 G-005更多真实 AssetBundle 样本、复杂字段解析、版本差异和任意结构重打包。
2. 继续 G-006:复杂 AssetBundle 兼容和真实样本覆盖;G-011D 的双 release 运维 V1
已完成,后续 retention scheduler 不属于本次闭环。
3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider
扩展体系。
4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。
+9 -12
View File
@@ -1,6 +1,6 @@
# Go 侧进度与边界(权威)
- **更新时间**2026-09-04
- **更新时间**2026-09-12
- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。
- **关联缺口**G-009(资源 bootstrap/分发);相关契约见 `docs/architecture/official-resource-backend.md` §7 和 `docs/guides/bat-api-local-live-smoke.md`
@@ -61,11 +61,11 @@
| ID | 约定 |
|---|---|
| F | 版本/清单经 **`bat.sock` JSON-RPC**`--socket`);不读 daemon 内部状态文件 |
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再 catalog/manifest |
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再轻量 **`release.attestation`**,再 catalog/manifestmanifest 请求绑定 attested release/publication/mapping/manifest identity 和 verification generation |
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
| I | 生产中 Rust `bat``bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 |
| J | 索引以 **manifest + 磁盘 Present/size** 为准 |
| J2 | RPC 状态以 Rust 返回的 `status` / `status_code` 为准;`bat-api` 只读消费,不自行推导同步状态 |
| J | Go 索引以 **manifest + 磁盘 Present/size** 建立可读快照,但不将其当作 release integrity |
| J2 | 普通 current 分发以 Rust `release.attestation` `ready`、identity、freshness 和 integrity/status code 为准;`bat-api` 只读消费,不自行推导 verifier`release.status` 保留为重型管理诊断 |
### 进程配置
@@ -86,7 +86,7 @@
| O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 |
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
| Q | 空目录标明 reserved empty |
| R | 默认门禁:`make test-go-api` + `make build-go-api` + `make check-docs`(无 FFI |
| R | 默认门禁:`make ci-check`;其中 Go 使用纯 API test/vet/build、required `golangci-lint 2.12.2`(无 FFI),缺失或版本不匹配失败 |
---
@@ -95,8 +95,8 @@
| 组件 | 路径 | 状态 | 说明 |
|---|---|---|---|
| Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``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.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``release.attestation/status/list/distribution/cleanup``catalog.*``parse.*``localized.status/publish/rollback``task.*``translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm/conflicts/resolve_conflict``translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`resource.manifest` typed params 固定 release/publication/manifest identity 和 verification generation`localized.publish` 的 typed params 支持 `translation_file``from_worker``patch_manifest` 三选一;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + `/v1/releases` + `/v1/distribution` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + release/localized/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` |
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI |
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
| 空骨架 | `api/``pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
@@ -116,10 +116,7 @@
```bash
# 默认(提交前 / CI 建议)
make test-go-api
make build-go-api
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
make check-docs
make ci-check
# 可选:改 FFI 或试验 CLI 时
make build-ffi
@@ -134,7 +131,7 @@ make build-go-cli # 产出 bin/bat-go
| 项 | 状态 |
|---|---|
| Go 同步 CLI | **边界已确定**(正式同步 CLI = Rust `bat` |
| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*``task.*``parse.*`、翻译任务/TM 状态查询与显式确认代理、内嵌 dashboard、同机 live smoke 和部署模板;持久化仍另议 |
| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*``task.*``parse.*``release.*` 双 release 查询/分发/cleanup 转发、翻译任务/TM 状态查询与显式确认代理、内嵌 dashboard、同机 live smoke 和部署模板;持久化仍另议 |
| G-010 Web | 内嵌 dashboard MVP 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 |
---
File diff suppressed because it is too large Load Diff
+415 -9
View File
@@ -260,6 +260,47 @@ fn translation_memory_commands_parse_and_validate() {
])
.unwrap();
assert_eq!(confirm.command, CliCommand::TranslationMemoryConfirm);
let supersede = parse(&[
"bat",
"i18n",
"memory",
"confirm",
"--tm-record-id",
"tm-new",
"--tm-supersede-record-id",
"tm-old",
"--tm-reviewer",
"reviewer",
"--tm-reason",
"replacement",
])
.unwrap();
assert_eq!(supersede.command, CliCommand::TranslationMemoryConfirm);
assert_eq!(
supersede.translation_memory_supersede_record_id.as_deref(),
Some("tm-old")
);
let conflicts = parse(&["bat", "i18n", "memory", "conflicts"]).unwrap();
assert_eq!(conflicts.command, CliCommand::TranslationMemoryConflicts);
let resolve = parse(&[
"bat",
"i18n",
"memory",
"resolve-conflict",
"--tm-record-id",
"tm-winner",
"--tm-expected-trusted-record-ids-json",
r#"["tm-old","tm-other"]"#,
"--tm-reviewer",
"reviewer",
"--tm-reason",
"selected",
])
.unwrap();
assert_eq!(
resolve.command,
CliCommand::TranslationMemoryResolveConflict
);
assert!(parse(&["bat", "i18n", "memory", "query"]).is_err());
assert!(parse(&[
"bat",
@@ -659,6 +700,19 @@ fn grouped_workflow_commands_use_short_top_level_aliases() {
assert_eq!(options.command, CliCommand::PublishLocalized);
assert!(options.translation_from_worker);
assert_eq!(options.localized_release_id.as_deref(), Some("localized-1"));
let options = parse(&[
"bat",
"i18n",
"publish",
"--patch-manifest",
"/tmp/patch-manifest.json",
])
.unwrap();
assert_eq!(options.command, CliCommand::PublishLocalized);
assert_eq!(
options.patch_manifest,
Some(PathBuf::from("/tmp/patch-manifest.json"))
);
assert!(parse(&[
"bat",
"i18n",
@@ -1537,6 +1591,8 @@ fn parses_explicit_source_and_disable_repair() {
"--no-repair",
"--unzip",
"/usr/bin/unzip",
"--zip",
"/usr/bin/zip",
])
.unwrap();
let config = options.config;
@@ -1547,6 +1603,7 @@ fn parses_explicit_source_and_disable_repair() {
assert!(!config.audit_local);
assert!(!config.repair);
assert_eq!(config.unzip_command, PathBuf::from("/usr/bin/unzip"));
assert_eq!(config.zip_command, PathBuf::from("/usr/bin/zip"));
assert!(matches!(
config.server_info_source,
Some(OfficialServerInfoSource::OfficialUrl(ref url))
@@ -2226,6 +2283,8 @@ fn daemon_child_args_preserve_sync_options() {
"http://127.0.0.1:7890",
"--unzip",
"/usr/bin/unzip",
"--zip",
"/usr/bin/zip",
"--interval",
"30m",
"--error-retry",
@@ -2242,6 +2301,9 @@ fn daemon_child_args_preserve_sync_options() {
assert!(args
.windows(2)
.any(|pair| pair == ["--output", "/tmp/daemon-output"]));
assert!(args
.windows(2)
.any(|pair| pair == ["--zip", "/usr/bin/zip"]));
assert!(args
.windows(2)
.any(|pair| pair == ["--localized-output", "/tmp/daemon-localized"]));
@@ -2893,6 +2955,19 @@ fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() {
"translation.memory.confirm",
Some(serde_json::json!({ "record_id": "tm-record", "reviewer": 42 })),
),
(
"translation.memory.conflicts",
Some(serde_json::json!({ "limit": "1" })),
),
(
"translation.memory.resolve_conflict",
Some(serde_json::json!({
"winner_record_id": "tm-winner",
"expected_trusted_record_ids": "tm-old",
"reviewer": "reviewer",
"reason": "selected"
})),
),
] {
let envelope = dispatch_rpc_method(
&rpc_request(method, params),
@@ -4160,7 +4235,7 @@ fn dispatch_catalog_status_reads_current_snapshot() {
"req-cat-1".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["ok"], true, "{value}");
assert_eq!(value["data"]["available"], true);
assert_eq!(value["data"]["bundle_version"], "bundle-b2");
assert_eq!(value["data"]["status"], "published");
@@ -4205,6 +4280,75 @@ fn dispatch_catalog_versions_lists_history() {
assert!(value["data"]["failed"].as_array().unwrap().is_empty());
}
#[cfg(unix)]
#[test]
fn dispatch_release_rpc_exposes_dual_release_queries_and_safe_cleanup_plan() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("output");
write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
let localized_root = temp.path().join("localized");
let tasks = test_task_context_with_config(OfficialUpdateConfig {
output_root: output_root.clone(),
localized_output_root: localized_root.clone(),
..OfficialUpdateConfig::default()
});
let status = dispatch_rpc_method(
&rpc_request("release.status", None),
&state_dir,
&new_daemon_control(),
&tasks,
"req-release-status".to_string(),
);
let status = serde_json::to_value(status).unwrap();
assert_eq!(status["ok"], true);
assert_eq!(status["data"]["default_distribution_channel"], "official");
assert_eq!(status["data"]["official_current_release_id"], "v-current");
let list = dispatch_rpc_method(
&rpc_request(
"release.list",
Some(serde_json::json!({"channel": "official"})),
),
&state_dir,
&new_daemon_control(),
&tasks,
"req-release-list".to_string(),
);
let list = serde_json::to_value(list).unwrap();
assert_eq!(list["ok"], true);
assert_eq!(list["data"]["channel"], "official");
assert_eq!(list["data"]["releases"][0]["channel"], "official");
let distribution = dispatch_rpc_method(
&rpc_request(
"release.distribution",
Some(serde_json::json!({"channel": "localized"})),
),
&state_dir,
&new_daemon_control(),
&tasks,
"req-release-distribution".to_string(),
);
let distribution = serde_json::to_value(distribution).unwrap();
assert_eq!(distribution["ok"], true);
assert_eq!(distribution["data"]["available"], false);
assert_eq!(distribution["data"]["channel"], "localized");
let cleanup = dispatch_rpc_method(
&rpc_request("release.cleanup", None),
&state_dir,
&new_daemon_control(),
&tasks,
"req-release-cleanup".to_string(),
);
let cleanup = serde_json::to_value(cleanup).unwrap();
assert_eq!(cleanup["ok"], true);
assert_eq!(cleanup["data"]["execute"], false);
assert!(cleanup["data"]["plan_id"].as_str().is_some());
}
#[test]
fn dispatch_catalog_diff_reports_bundle_change() {
let temp = tempfile::TempDir::new().unwrap();
@@ -4315,6 +4459,8 @@ fn catalog_refresh_config_is_dry_run_plan_only() {
#[test]
fn dispatch_resource_manifest_paginates() {
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("output");
@@ -4342,16 +4488,80 @@ fn dispatch_resource_manifest_paginates() {
},
},
});
let mut manifest: bat_infrastructure::OfficialDownloadManifest =
serde_json::from_value(manifest).unwrap();
let mapping_identity = bat_infrastructure::official_distribution_mapping_identity(&manifest);
manifest.distribution_mapping_identity = Some(mapping_identity.clone());
manifest.destination_index = manifest
.entries
.values()
.map(|entry| (entry.destination.clone(), entry.url.clone()))
.collect();
let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
fs::write(
current_dir.join("official-download-manifest.json"),
serde_json::to_vec(&manifest).unwrap(),
&manifest_bytes,
)
.unwrap();
let manifest_identity = blake3::hash(&manifest_bytes).to_hex().to_string();
let publication_identity = format!("odp-v1-{mapping_identity}-{manifest_identity}");
fs::write(
current_dir.join("official-distribution-publication.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"official_release_id": "v-current",
"mapping_identity": mapping_identity,
"manifest_identity": manifest_identity,
"entry_count": 3,
}))
.unwrap(),
)
.unwrap();
fs::write(
current_dir.join("official-distribution-attestation.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"channel": "official",
"official_release_id": "v-current",
"resource_root": current_dir,
"publication_identity": publication_identity,
"mapping_identity": mapping_identity,
"manifest_identity": manifest_identity,
"entry_count": 3,
"integrity_status": "verified",
"status": "ready",
"status_code": "distribution.ready",
"ready": true,
"verification_generation": 1,
"verified_at": unix_seconds_now(),
"max_age_seconds": 7260,
}))
.unwrap(),
)
.unwrap();
symlink(
Path::new("versions").join("v-current"),
output_root.join("current"),
)
.unwrap();
let bound_params = serde_json::json!({
"release_id": "v-current",
"expected_publication_identity": publication_identity,
"expected_manifest_identity": manifest_identity,
"expected_verification_generation": 1,
});
let envelope = dispatch_rpc_method(
&rpc_request(
"resource.manifest",
Some(serde_json::json!({ "offset": 1, "limit": 2 })),
Some(serde_json::json!({
"release_id": bound_params["release_id"],
"expected_publication_identity": bound_params["expected_publication_identity"],
"expected_manifest_identity": bound_params["expected_manifest_identity"],
"expected_verification_generation": bound_params["expected_verification_generation"],
"offset": 1,
"limit": 2,
})),
),
&state_dir,
&new_daemon_control(),
@@ -4363,6 +4573,9 @@ fn dispatch_resource_manifest_paginates() {
assert_eq!(value["data"]["available"], true);
assert_eq!(value["data"]["total_entries"], 3);
assert_eq!(value["data"]["offset"], 1);
assert_eq!(value["data"]["release_id"], "v-current");
assert!(value["data"]["manifest_identity"].as_str().is_some());
assert!(value["data"]["generation"].as_u64().is_some());
let entries = value["data"]["entries"].as_array().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["destination"], "b");
@@ -4370,7 +4583,16 @@ fn dispatch_resource_manifest_paginates() {
// 非法 limit → 参数错误。
let envelope = dispatch_rpc_method(
&rpc_request("resource.manifest", Some(serde_json::json!({ "limit": 0 }))),
&rpc_request(
"resource.manifest",
Some(serde_json::json!({
"release_id": bound_params["release_id"],
"expected_publication_identity": bound_params["expected_publication_identity"],
"expected_manifest_identity": bound_params["expected_manifest_identity"],
"expected_verification_generation": bound_params["expected_verification_generation"],
"limit": 0,
})),
),
&state_dir,
&new_daemon_control(),
&test_task_context(),
@@ -4383,7 +4605,14 @@ fn dispatch_resource_manifest_paginates() {
let envelope = dispatch_rpc_method(
&rpc_request(
"resource.list",
Some(serde_json::json!({ "offset": 2, "limit": 1 })),
Some(serde_json::json!({
"release_id": bound_params["release_id"],
"expected_publication_identity": bound_params["expected_publication_identity"],
"expected_manifest_identity": bound_params["expected_manifest_identity"],
"expected_verification_generation": bound_params["expected_verification_generation"],
"offset": 2,
"limit": 1,
})),
),
&state_dir,
&new_daemon_control(),
@@ -4396,6 +4625,151 @@ fn dispatch_resource_manifest_paginates() {
let entries = value["data"]["entries"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["destination"], "c");
let envelope = dispatch_rpc_method(
&rpc_request(
"resource.manifest",
Some(serde_json::json!({
"release_id": "v-current",
"expected_publication_identity": publication_identity,
"expected_manifest_identity": "wrong-generation",
"expected_verification_generation": 1,
"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);
}
#[test]
fn dispatch_resource_manifest_rejects_previous_verification_generation() {
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("output");
let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None);
let manifest = serde_json::json!({
"version": 1,
"entries": {
"https://prod-clientpatch.bluearchiveyostar.com/a": {
"url": "https://prod-clientpatch.bluearchiveyostar.com/a",
"destination": "a",
"bytes": 1,
"blake3": blake3::hash(b"a").to_hex().to_string(),
},
"https://prod-clientpatch.bluearchiveyostar.com/b": {
"url": "https://prod-clientpatch.bluearchiveyostar.com/b",
"destination": "b",
"bytes": 1,
"blake3": blake3::hash(b"b").to_hex().to_string(),
},
},
});
let mut manifest: bat_infrastructure::OfficialDownloadManifest =
serde_json::from_value(manifest).unwrap();
manifest.distribution_mapping_identity = Some(
bat_infrastructure::official_distribution_mapping_identity(&manifest),
);
manifest.destination_index = manifest
.entries
.values()
.map(|entry| (entry.destination.clone(), entry.url.clone()))
.collect();
let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
fs::write(
current_dir.join("official-download-manifest.json"),
&manifest_bytes,
)
.unwrap();
fs::write(current_dir.join("a"), b"a").unwrap();
fs::write(current_dir.join("b"), b"b").unwrap();
symlink(
Path::new("versions").join("v-current"),
output_root.join("current"),
)
.unwrap();
let mapping_identity = bat_infrastructure::official_distribution_mapping_identity(&manifest);
let manifest_identity = blake3::hash(&manifest_bytes).to_hex().to_string();
let publication_identity = format!("odp-v1-{mapping_identity}-{manifest_identity}");
let write_attestation = |generation: u64| {
fs::write(
current_dir.join("official-distribution-publication.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"official_release_id": "v-current",
"mapping_identity": mapping_identity,
"manifest_identity": manifest_identity,
"entry_count": 2,
}))
.unwrap(),
)
.unwrap();
fs::write(
current_dir.join("official-distribution-attestation.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"channel": "official",
"official_release_id": "v-current",
"resource_root": current_dir,
"publication_identity": publication_identity,
"mapping_identity": mapping_identity,
"manifest_identity": manifest_identity,
"entry_count": 2,
"integrity_status": "verified",
"status": "ready",
"status_code": "distribution.ready",
"ready": true,
"verification_generation": generation,
"verified_at": unix_seconds_now(),
"max_age_seconds": 7260,
}))
.unwrap(),
)
.unwrap();
};
write_attestation(4);
let bound_params = serde_json::json!({
"release_id": "v-current",
"expected_publication_identity": publication_identity,
"expected_manifest_identity": manifest_identity,
"expected_verification_generation": 4,
"offset": 0,
"limit": 1,
});
let envelope = dispatch_rpc_method(
&rpc_request("resource.manifest", Some(bound_params.clone())),
&state_dir,
&new_daemon_control(),
&test_task_context(),
"req-generation-1".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["generation"], 4);
assert_eq!(
value["data"]["resource_root"],
current_dir.to_string_lossy().as_ref()
);
write_attestation(5);
let envelope = dispatch_rpc_method(
&rpc_request("resource.manifest", Some(bound_params)),
&state_dir,
&new_daemon_control(),
&test_task_context(),
"req-generation-2".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], false);
}
fn write_resource_index_fixture(repository_path: &Path) {
@@ -5065,7 +5439,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("output");
write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
let official_version = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
let localized_root = temp.path().join("localized");
let localized_version = localized_root
.join(LOCALIZED_VERSIONS_DIR)
@@ -5076,6 +5450,8 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
localized_root.join(LOCALIZED_CURRENT_LINK),
)
.unwrap();
fs::write(official_version.join("data.bin"), b"official").unwrap();
fs::write(localized_version.join("data.bin"), b"localized").unwrap();
fs::write(
localized_root.join(LOCALIZED_VERSION_STATE_FILE),
serde_json::to_vec(&bat_infrastructure::LocalizedVersionState {
@@ -5096,9 +5472,19 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
official_release_id: "v-current".to_string(),
localized_release_id: "v-current".to_string(),
generated_unix_seconds: 124,
file_count: 0,
file_count: 1,
text_asset_operation_count: 0,
files: Vec::new(),
files: vec![bat_infrastructure::LocalizedPatchFile {
path: "data.bin".to_string(),
original_blake3: blake3::hash(b"official").to_hex().to_string(),
localized_blake3: blake3::hash(b"localized").to_hex().to_string(),
original_bytes: 8,
localized_bytes: 9,
byte_delta: 1,
text_asset_operations: Vec::new(),
operations: Vec::new(),
}],
patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: None,
remove_version_path: localized_version.clone(),
@@ -5130,7 +5516,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
assert_eq!(value["data"]["current_points_to_published_version"], true);
assert_eq!(value["data"]["patch_manifest_available"], true);
assert_eq!(value["data"]["patch_manifest_matches_release"], true);
assert_eq!(value["data"]["patch_file_count"], 0);
assert_eq!(value["data"]["patch_file_count"], 1);
assert_eq!(value["data"]["patch_text_asset_operation_count"], 0);
assert_eq!(
value["data"]["translation_workflow_status"],
@@ -5140,6 +5526,23 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
value["data"]["published_version_path"].as_str().unwrap(),
localized_version.to_string_lossy()
);
assert_eq!(value["data"]["patch_manifest_contract_status"], "legacy");
assert_eq!(value["data"]["artifact_integrity_status"], "valid");
fs::write(localized_version.join("data.bin"), b"corrupt").unwrap();
let envelope = dispatch_rpc_method(
&rpc_request("localized.status", None),
&state_dir,
&new_daemon_control(),
&tasks,
"req-loc-corrupt".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["status_code"], "localized.degraded");
assert_eq!(value["data"]["localized_release_status"], "degraded");
assert_eq!(value["data"]["artifact_integrity_status"], "invalid");
assert_eq!(value["data"]["artifact_integrity_verified"], false);
}
#[test]
@@ -5235,6 +5638,7 @@ fn dispatch_localized_rollback_restores_manifest_previous_release() {
file_count: 0,
text_asset_operation_count: 0,
files: Vec::new(),
patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: None,
remove_version_path: previous.clone(),
@@ -5253,6 +5657,7 @@ fn dispatch_localized_rollback_restores_manifest_previous_release() {
file_count: 0,
text_asset_operation_count: 0,
files: Vec::new(),
patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: Some(PathBuf::from("versions/release-1")),
remove_version_path: current.clone(),
@@ -5354,6 +5759,7 @@ fn localized_status_keeps_published_release_during_manual_proofreading() {
file_count: 0,
text_asset_operation_count: 0,
files: Vec::new(),
patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: None,
remove_version_path: localized_version.clone(),
+13
View File
@@ -61,6 +61,7 @@ import_resource_repository_path = ''
curl_command = 'curl'
proxy = 'auto'
unzip_command = 'unzip'
zip_command = 'zip'
download_concurrency = 8
[translation.worker]
@@ -138,6 +139,7 @@ struct NetworkSection {
curl_command: Option<PathBuf>,
proxy: Option<CurlProxyConfig>,
unzip_command: Option<PathBuf>,
zip_command: Option<PathBuf>,
download_concurrency: Option<usize>,
}
@@ -356,6 +358,9 @@ impl BatConfigFile {
if let Some(value) = self.network.unzip_command.as_ref() {
options.config.unzip_command = value.clone();
}
if let Some(value) = self.network.zip_command.as_ref() {
options.config.zip_command = value.clone();
}
if let Some(value) = self.network.download_concurrency {
options.config.download_concurrency = value;
}
@@ -575,6 +580,13 @@ impl BatConfigFile {
line_number,
)?);
}
(SectionPath::Network, "zip_command") => {
self.network.zip_command = Some(parse_required_path(
value,
"network.zip_command",
line_number,
)?);
}
(SectionPath::Network, "download_concurrency") => {
self.network.download_concurrency = Some(parse_download_concurrency(
&parse_scalar_text(value, "network.download_concurrency", line_number)?,
@@ -1151,6 +1163,7 @@ import_resource_repository_path = '/srv/resources.sqlite'
curl_command = '/usr/bin/curl'
proxy = 'http://127.0.0.1:7890'
unzip_command = '/usr/bin/unzip'
zip_command = '/usr/bin/zip'
download_concurrency = 16
[translation.worker]
@@ -172,6 +172,7 @@ fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Re
if options.patch_kind.is_some()
|| options.patch_source_path.is_some()
|| options.patch_patch_path.is_some()
|| options.patch_manifest.is_some()
{
return Err(anyhow::anyhow!(
"{command} 不接受 --patch-kind、--source-file 或 --patch-file"
@@ -191,6 +192,7 @@ fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::
|| options.unityfs_expected_value.is_some()
|| options.unityfs_replacement_value.is_some()
|| options.unityfs_expected_semantic_value.is_some()
|| options.patch_manifest.is_some()
{
return Err(anyhow::anyhow!(
"{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令"
+25 -1
View File
@@ -537,8 +537,8 @@ pub(super) fn run_task_worker(
registry: TaskRegistry,
sync_lock: Arc<Mutex<()>>,
control: DaemonControl,
service: OfficialUpdateService,
) {
let service = OfficialUpdateService::new();
for job in receiver {
registry.update(&job.id, |record| {
record.status = "running";
@@ -602,6 +602,30 @@ pub(super) fn run_task_worker(
},
)
};
let run_result = if job.kind == TaskKind::Verify {
match run_result {
Ok(report) => {
bat_infrastructure::verify_and_record_official_distribution_attestation(
&job.config,
service.attestation_max_age_seconds(),
)
.map(|_| report)
}
Err(error) => {
// If the verifier failed before returning its report
// (for example, a malformed manifest), make a best
// effort to revoke the previous ready generation.
let _ =
bat_infrastructure::verify_and_record_official_distribution_attestation(
&job.config,
service.attestation_max_age_seconds(),
);
Err(error)
}
}
} else {
run_result
};
run_result
.map(|report| serde_json::to_value(&report).map_err(anyhow::Error::from))
.and_then(|result| result)
@@ -422,6 +422,7 @@ Sync:
--proxy <URL|auto|none> curl proxy override (default: auto from env)
--no-proxy Force direct curl connections
--unzip <PATH> unzip executable (default: unzip)
--zip <PATH> zip executable (default: zip)
--dry-run Do not write sync state
--plan Include planned URLs in dry-run
--force Force download/refresh
@@ -448,6 +449,7 @@ Sync:
--glossary-reviewer <ID> Reviewer for Glossary updates/reviews/delete
--glossary-reason <TEXT> Reason for Glossary review/delete or override
--glossary-provenance <TEXT> Provenance for an explicit Glossary override
--glossary-qa-identity <ID> Current blocking Glossary QA identity for an override
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
--worker-max-attempts <N> Maximum claims per translation task
--worker-lease-seconds <N> Lease seconds for one claimed task
+165 -16
View File
@@ -1,6 +1,6 @@
use super::report_output::print_json_value;
use super::*;
use bat_core::domain::{GlossaryOverride, TranslationMemoryContext};
use bat_core::domain::{validate_glossary_override, GlossaryOverride, TranslationMemoryContext};
use bat_core::repositories::TranslationMemoryRepository;
use std::collections::BTreeMap;
@@ -377,24 +377,19 @@ async fn build_manual_translation_results(
} else {
None
};
if let Some(qa) = glossary_qa.as_ref().filter(|qa| qa.status.is_blocked()) {
let Some(override_record) = param.glossary_override.as_ref() else {
if let Some(qa) = glossary_qa.as_ref() {
if qa.status.is_blocked() {
validate_glossary_override(qa, param.glossary_override.as_ref()).map_err(
|error| {
anyhow::anyhow!("TextUnit {} 的 glossary_override 无效:{error}", unit_id)
},
)?;
} else if param.glossary_override.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 的 Glossary QA blocked;必须提供 glossary_override",
unit_id
));
};
if override_record.reviewer.trim().is_empty()
|| override_record.reason.trim().is_empty()
|| override_record.provenance.trim().is_empty()
|| override_record.confirmed_unix_seconds == 0
{
return Err(anyhow::anyhow!(
"TextUnit {} 的 glossary_override 不完整或 confirmed_unix_seconds 无效",
"TextUnit {} 不能为非 blocking Glossary QA 指定 override",
unit_id
));
}
let _ = qa;
} else if param.glossary_override.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 不能为非 blocking Glossary QA 指定 override",
@@ -438,6 +433,10 @@ pub(super) fn run_translation_memory_command(options: &CliOptions) -> anyhow::Re
CliCommand::TranslationMemorySummary => RPC_METHOD_TRANSLATION_MEMORY_SUMMARY,
CliCommand::TranslationMemoryQuery => RPC_METHOD_TRANSLATION_MEMORY_QUERY,
CliCommand::TranslationMemoryConfirm => RPC_METHOD_TRANSLATION_MEMORY_CONFIRM,
CliCommand::TranslationMemoryConflicts => RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS,
CliCommand::TranslationMemoryResolveConflict => {
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT
}
_ => return Err(anyhow::anyhow!("不是 Translation Memory 命令")),
};
if daemon_rpc_available(&options.state_dir)
@@ -485,6 +484,34 @@ pub(super) fn run_translation_memory_command(options: &CliOptions) -> anyhow::Re
record_id,
reviewer,
options.translation_memory_reason.clone(),
options.translation_memory_supersede_record_id.as_deref(),
)?
}
CliCommand::TranslationMemoryConflicts => {
build_translation_memory_conflicts_report(&path, options.query_limit)?
}
CliCommand::TranslationMemoryResolveConflict => {
let winner = options
.translation_memory_record_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 winner record"))?;
let expected = options
.translation_memory_expected_trusted_record_ids_json
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 expected set"))?;
let expected = serde_json::from_str::<Vec<String>>(expected).map_err(|error| {
anyhow::anyhow!("expected trusted record IDs 必须是 JSON array{error}")
})?;
let reviewer = options
.translation_memory_reviewer
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reviewer"))?;
let reason = options
.translation_memory_reason
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reason"))?;
build_translation_memory_resolve_conflict_report(
&path, winner, &expected, reviewer, reason,
)?
}
_ => unreachable!(),
@@ -543,10 +570,49 @@ fn translation_memory_cli_params(
.ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-reviewer"))?;
params.insert("record_id".to_string(), serde_json::json!(record_id));
params.insert("reviewer".to_string(), serde_json::json!(reviewer));
if let Some(supersede_record_id) =
options.translation_memory_supersede_record_id.as_deref()
{
params.insert(
"supersede_record_id".to_string(),
serde_json::json!(supersede_record_id),
);
}
if let Some(reason) = options.translation_memory_reason.as_deref() {
params.insert("reason".to_string(), serde_json::json!(reason));
}
}
CliCommand::TranslationMemoryConflicts => {
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
}
CliCommand::TranslationMemoryResolveConflict => {
let winner = options
.translation_memory_record_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 winner record"))?;
let expected = options
.translation_memory_expected_trusted_record_ids_json
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 expected set"))?;
let reviewer = options
.translation_memory_reviewer
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reviewer"))?;
let reason = options
.translation_memory_reason
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reason"))?;
let expected = serde_json::from_str::<Vec<String>>(expected).map_err(|error| {
anyhow::anyhow!("expected trusted record IDs 必须是 JSON array{error}")
})?;
params.insert("winner_record_id".to_string(), serde_json::json!(winner));
params.insert(
"expected_trusted_record_ids".to_string(),
serde_json::json!(expected),
);
params.insert("reviewer".to_string(), serde_json::json!(reviewer));
params.insert("reason".to_string(), serde_json::json!(reason));
}
_ => unreachable!(),
}
Ok(Some(serde_json::Value::Object(params)))
@@ -630,6 +696,7 @@ pub(super) fn build_translation_memory_confirm_report(
record_id: &str,
reviewer: &str,
reason: Option<String>,
supersede_record_id: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
if record_id.trim().is_empty() || reviewer.trim().is_empty() {
return Err(anyhow::anyhow!(
@@ -650,7 +717,89 @@ pub(super) fn build_translation_memory_confirm_report(
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.confirm(record_id, reviewer, reason)
.confirm_with_supersede(record_id, reviewer, reason, supersede_record_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"entry": entry,
}))
}
pub(super) fn build_translation_memory_conflicts_report(
path: &std::path::Path,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
if !(1..=1000).contains(&limit) {
return Err(anyhow::anyhow!(
"TM conflicts 的 limit 必须在 1..=1000 范围内"
));
}
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"conflicts": [],
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let conflicts = runtime.block_on(async {
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.list_conflicts(limit)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"conflicts": conflicts,
}))
}
pub(super) fn build_translation_memory_resolve_conflict_report(
path: &std::path::Path,
winner_record_id: &str,
expected_trusted_record_ids: &[String],
reviewer: &str,
reason: &str,
) -> anyhow::Result<serde_json::Value> {
if winner_record_id.trim().is_empty()
|| reviewer.trim().is_empty()
|| reason.trim().is_empty()
|| expected_trusted_record_ids.is_empty()
{
return Err(anyhow::anyhow!(
"TM resolve-conflict 必须指定 winner_record_id、expected_trusted_record_ids、reviewer 和 reason"
));
}
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
return Err(anyhow::anyhow!(
"Translation Memory 数据库不存在:{}",
path.display()
));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let entry = runtime.block_on(async {
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.resolve_conflict(
winner_record_id,
expected_trusted_record_ids,
reviewer,
reason,
)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
@@ -150,10 +150,12 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> {
options.glossary_reviewer.as_deref(),
options.glossary_reason.as_deref(),
options.glossary_override_provenance.as_deref(),
options.glossary_qa_identity.as_deref(),
) {
(None, None, None) => None,
(Some(reviewer), Some(reason), Some(provenance)) => Some(
(None, None, None, None) => None,
(Some(reviewer), Some(reason), Some(provenance), Some(qa_identity)) => Some(
bat_core::domain::GlossaryOverride {
qa_identity: qa_identity.to_string(),
reviewer: reviewer.to_string(),
reason: reason.to_string(),
provenance: provenance.to_string(),
@@ -162,7 +164,7 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> {
),
_ => {
return Err(anyhow::anyhow!(
"Glossary override 必须同时指定 --glossary-reviewer、--glossary-reason 和 --glossary-provenance"
"Glossary override 必须同时指定 --glossary-qa-identity、--glossary-reviewer、--glossary-reason 和 --glossary-provenance"
))
}
};
@@ -337,6 +339,49 @@ pub(super) fn publish_localized_report(
options: &CliOptions,
) -> anyhow::Result<LocalizedPatchReport> {
let (resource_root, official_release_id) = current_official_release(options)?;
if let Some(manifest_path) = options.patch_manifest.as_ref() {
let manifest_bytes = read_file_no_symlink(manifest_path, "generic patch manifest")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| {
anyhow::anyhow!("generic patch manifest 不存在:{}", manifest_path.display())
})?;
let manifest: bat_patch::PatchManifest = serde_json::from_slice(&manifest_bytes)
.map_err(|error| anyhow::anyhow!("generic patch manifest 无效:{error}"))?;
bat_patch::validate_patch_manifest(&manifest)
.map_err(|error| anyhow::anyhow!("generic patch manifest 无效:{error}"))?;
if manifest.source_version != official_release_id {
return Err(anyhow::anyhow!(
"generic patch source version={} 与当前官方 release={} 不一致",
manifest.source_version,
official_release_id
));
}
let localized_release_id = options
.localized_release_id
.clone()
.unwrap_or_else(|| manifest.target_version.clone());
if localized_release_id != manifest.target_version {
return Err(anyhow::anyhow!(
"generic patch target version={} 必须与 localized release id={} 一致",
manifest.target_version,
localized_release_id
));
}
let config = LocalizedPatchConfig::new(
resource_root,
options.config.localized_output_root.clone(),
official_release_id,
Vec::new(),
)
.with_archive_commands(
options.config.unzip_command.clone(),
options.config.zip_command.clone(),
)
.with_manifest(manifest)
.with_localized_release_id(localized_release_id)
.with_force(options.config.force);
return LocalizedPatchService::new().publish(&config);
}
let workbench = if options.translation_from_worker {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -371,7 +416,11 @@ pub(super) fn publish_localized_report(
&workbench,
options.glossary_path.as_deref(),
)?;
let operations = localized_patch_operations(&resource_root, &workbench)?;
let operations = localized_patch_operations_with_glossary_path(
&resource_root,
&workbench,
options.glossary_path.as_deref(),
)?;
let localized_release_id = options.localized_release_id.clone().or_else(|| {
options
.config
@@ -384,6 +433,10 @@ pub(super) fn publish_localized_report(
official_release_id,
Vec::new(),
)
.with_archive_commands(
options.config.unzip_command.clone(),
options.config.zip_command.clone(),
)
.with_operations(operations)
.with_force(options.config.force);
if let Some(release_id) = localized_release_id {
+24
View File
@@ -30,6 +30,30 @@ impl FileSystemCasRepository {
self.engine().await.map(|_| ())
}
/// Releases one release-owned CAS reference exactly once.
pub async fn release_reference_once(
&self,
ownership_id: &str,
ordinal: u64,
id: &ObjectId,
) -> bat_core::Result<bool> {
let hash = Self::parse_object_id(id)?;
self.engine()
.await?
.release_reference_once(ownership_id, ordinal, &hash)
.await
.map_err(Self::map_error)
}
/// Returns whether the durable release ownership ledger has any row.
pub async fn has_release_ownership(&self, ownership_id: &str) -> bat_core::Result<bool> {
self.engine()
.await?
.has_release_ownership(ownership_id)
.await
.map_err(Self::map_error)
}
async fn engine(&self) -> bat_core::Result<&engine_repository::FileSystemCasRepository> {
self.inner
.get_or_try_init(|| async {
File diff suppressed because it is too large Load Diff
+33 -18
View File
@@ -29,7 +29,9 @@ pub mod official_update;
pub mod patch_ops;
pub mod path_security;
pub mod release_flow;
pub mod release_ops;
pub mod resources;
mod sqlite_migration;
pub mod translation_memory;
pub mod translation_tasks;
pub mod translation_worker;
@@ -53,14 +55,17 @@ pub use import::{
ResourceImportService,
};
pub use localized_patch::{
inspect_localized_release_artifact, inspect_localized_release_artifact_at,
mark_localized_manual_proofreading, read_localized_patch_manifest_at,
read_localized_version_state, write_localized_version_state, LocalizedFieldPatch,
read_localized_version_state, write_localized_version_state, LocalizedArtifactIntegrityReport,
LocalizedDistributionEntry, LocalizedDistributionManifest, LocalizedFieldPatch,
LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput, LocalizedPatchIntegrity,
LocalizedPatchManifest, LocalizedPatchOperation, LocalizedPatchOperationMetadata,
LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService,
LocalizedRollbackReport, LocalizedStringFieldPatch, LocalizedTextAssetPatch,
LocalizedTranslationWorkflowReport, LocalizedVersionState, LOCALIZED_CURRENT_LINK,
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR,
LOCALIZED_DISTRIBUTION_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_FILE,
LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR,
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR,
LOCALIZED_VERSION_STATE_FILE, LOCALIZED_VERSION_STATE_VERSION,
@@ -76,15 +81,19 @@ pub use official_changes::{
OFFICIAL_RESOURCE_CHANGES_VERSION,
};
pub use official_download::{
read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references,
DownloadError, OfficialCasReuseReferenceManifest, OfficialDownloadManifest,
official_distribution_mapping_identity, official_distribution_max_age_for_durations,
official_distribution_max_age_seconds, read_cas_reuse_reference_manifest_at,
read_download_manifest_at, release_cas_reuse_references, DownloadError,
OfficialCasReuseReferenceManifest, OfficialDistributionAttestation, OfficialDownloadManifest,
OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem,
OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus,
OfficialLocalVerificationReport, OfficialResourceHashAlgorithm,
OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress,
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
OfficialResourcePullStatus, OfficialResourceReuseWarning, OfficialResourceVerification,
OFFICIAL_CAS_REUSE_REFERENCES_FILE,
DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS, DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS,
OFFICIAL_CAS_REUSE_REFERENCES_FILE, OFFICIAL_DISTRIBUTION_ATTESTATION_FILE,
OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION, OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
};
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
pub use official_launcher::{
@@ -125,13 +134,13 @@ pub use official_textunit_queue::{
pub use official_update::{
cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging,
gc_orphan_staging_with_cas_root, read_bootstrap_cache, read_snapshot, read_version_state,
write_bootstrap_cache, write_snapshot, write_version_state, ExtendedSnapshotDelta,
GameMainConfigSnapshot, LauncherMetadataSnapshot, LocalizedReleaseStatus,
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot,
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState,
ResolvedBootstrap,
verify_and_record_official_distribution_attestation, write_bootstrap_cache, write_snapshot,
write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
LocalizedReleaseStatus, OfficialBootstrapCache, OfficialEndpointMarkerRole,
OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
OfficialVersionRecord, OfficialVersionState, ResolvedBootstrap,
};
pub use patch_ops::{
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
@@ -145,6 +154,12 @@ pub use path_security::{
validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE,
};
pub use release_flow::ReleaseFlowStatusCode;
pub use release_ops::{
build_official_distribution_attestation, build_release_list, build_release_status,
cleanup_releases, select_release_distribution, OfficialDistributionAttestationReport,
ReleaseCleanupParams, ReleaseCleanupReport, ReleaseDistributionEntry, ReleaseDistributionPage,
ReleaseDistributionParams, ReleaseListParams, ReleaseStatusReport, ReleaseSummary,
};
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
pub use translation_memory::{
translation_memory_context, translation_memory_repository_path,
@@ -173,12 +188,12 @@ pub use translation_worker::{
pub use translation_workflow::{
completed_worker_translation_workbench, export_completed_worker_translation_workbench,
export_translation_workbench, get_translation_entry, localized_patch_operations,
localized_text_asset_patches, read_translation_workbench, repack_bundle, set_translation,
set_translation_checked, set_translation_checked_with_glossary_path, unset_translation,
validate_translation_workbench, validate_translation_workbench_with_glossary_path,
write_translation_workbench, RepackOperation, RepackReport, RepackSpec, TranslationWorkbench,
TranslationWorkbenchEntry, TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION,
TRANSLATION_WORKBENCH_VERSION,
localized_patch_operations_with_glossary_path, localized_text_asset_patches,
read_translation_workbench, repack_bundle, set_translation, set_translation_checked,
set_translation_checked_with_glossary_path, unset_translation, validate_translation_workbench,
validate_translation_workbench_with_glossary_path, write_translation_workbench,
RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION,
};
/// Infrastructure 版本号
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+544 -20
View File
@@ -13,6 +13,11 @@ use crate::official_changes::{
write_official_resource_change_handoff, OfficialResourceChangeHandoffReport,
OfficialResourceChangeSummary,
};
use crate::official_download::{
official_distribution_max_age_for_durations, write_official_distribution_attestation_at,
write_official_distribution_publication_anchor_at, OfficialDistributionAttestation,
OFFICIAL_CAS_REUSE_REFERENCES_FILE, OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
};
use crate::official_game_main_config::{
resolve_game_main_config_source, OfficialGameMainConfigSelectedSource,
OfficialGameMainConfigSourceKind,
@@ -126,6 +131,8 @@ pub struct OfficialUpdateConfig {
pub download_concurrency: usize,
/// Unzip command used when a metadata change requires GameMainConfig parsing.
pub unzip_command: PathBuf,
/// Zip command used when publishing localized bundles nested in ZIP archives.
pub zip_command: PathBuf,
/// Dry run reports decisions and optional plan URLs without writing sync state.
pub dry_run: bool,
/// Include full download URLs when dry-running.
@@ -162,6 +169,7 @@ impl Default for OfficialUpdateConfig {
curl_proxy: CurlProxyConfig::default(),
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
unzip_command: PathBuf::from("unzip"),
zip_command: PathBuf::from("zip"),
dry_run: false,
plan: false,
force: false,
@@ -1125,7 +1133,20 @@ impl OfficialPublishLayout {
if !path_exists_no_follow(active_root)? {
return Ok(());
}
copy_tree_no_symlink(active_root, staging_root, active_root == self.root)
copy_tree_no_symlink(active_root, staging_root, active_root == self.root)?;
if active_root != self.root {
let cas_references = staging_root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE);
if path_exists_no_follow(&cas_references)? {
ensure_safe_file_target(staging_root, &cas_references, "staging CAS 引用清单")?;
fs::remove_file(&cas_references).map_err(|error| {
format!(
"清理 active release CAS ownership 清单失败 {}{error}",
cas_references.display()
)
})?;
}
}
Ok(())
}
fn legacy_manifest_exists(&self) -> Result<bool, String> {
@@ -1192,13 +1213,48 @@ impl OfficialPublishLayout {
}
/// Official update runner.
#[derive(Debug, Clone, Default)]
pub struct OfficialUpdateService;
#[derive(Debug, Clone)]
pub struct OfficialUpdateService {
attestation_max_age_seconds: u64,
}
impl Default for OfficialUpdateService {
fn default() -> Self {
Self::new()
}
}
impl OfficialUpdateService {
/// Creates an official update runner.
pub fn new() -> Self {
Self
Self {
attestation_max_age_seconds: official_distribution_max_age_for_durations(
std::time::Duration::from_secs(
crate::official_download::DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS,
),
std::time::Duration::from_secs(
crate::official_download::DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS,
),
),
}
}
/// Creates an update runner using the daemon's actual watch cadence.
pub fn with_verification_cadence(
verification_interval: std::time::Duration,
error_retry: std::time::Duration,
) -> Self {
Self {
attestation_max_age_seconds: official_distribution_max_age_for_durations(
verification_interval,
error_retry,
),
}
}
/// Returns the freshness window persisted with each attestation.
pub fn attestation_max_age_seconds(&self) -> u64 {
self.attestation_max_age_seconds
}
/// Executes one official update run.
@@ -1586,11 +1642,24 @@ impl OfficialUpdateService {
fetcher.download_manifest_path().display()
),
));
Some(
fetcher
.audit_local_manifest(&pull_plan)
.map_err(anyhow::Error::msg)?,
)
match fetcher.audit_local_manifest(&pull_plan) {
Ok(audit) => Some(audit),
Err(error) => {
if !config.dry_run && has_current_pointer {
let release_id = version_id_from_path(&active_resource_root)
.unwrap_or_else(|| fallback_version_id(&current_update_snapshot));
let _ = write_official_distribution_attestation_at(
&active_resource_root,
&active_resource_root,
&release_id,
"invalid",
self.attestation_max_age_seconds,
vec![format!("本地 manifest 审计失败:{error}")],
);
}
return Err(anyhow::Error::msg(error));
}
}
} else if config.audit_local {
progress(OfficialUpdateProgress::new(
"audit",
@@ -1652,6 +1721,31 @@ impl OfficialUpdateService {
check_shutdown_requested(&mut should_cancel)?;
let active_release_id = version_id_from_path(&active_resource_root)
.unwrap_or_else(|| fallback_version_id(&current_update_snapshot));
let mut local_attestation_invalidated = false;
if !config.dry_run
&& has_current_pointer
&& local_audit.as_ref().is_some_and(|audit| !audit.is_clean())
{
let diagnostics = local_audit
.as_ref()
.map(|audit| {
vec![format!(
"本地 manifest 审计失败:{} 项需要修复",
audit.repair_needed_count()
)]
})
.unwrap_or_default();
write_official_distribution_attestation_at(
&active_resource_root,
&active_resource_root,
&active_release_id,
"invalid",
self.attestation_max_age_seconds,
diagnostics,
)
.map_err(anyhow::Error::msg)?;
local_attestation_invalidated = true;
}
let localized_info = localized_release_info_for(config, Some(active_release_id.as_str()));
let mut report = OfficialUpdateReport {
update_status: if should_download {
@@ -1765,6 +1859,32 @@ impl OfficialUpdateService {
&active_resource_root,
&snapshot_path,
)?;
if !local_attestation_invalidated {
if let Some(audit) = local_audit.as_ref() {
let integrity_status = if audit.is_clean() {
"verified"
} else {
"invalid"
};
let diagnostics = if audit.is_clean() {
Vec::new()
} else {
vec![format!(
"本地 manifest 审计失败:{} 项需要修复",
audit.repair_needed_count()
)]
};
write_official_distribution_attestation_at(
&active_resource_root,
&active_resource_root,
&active_release_id,
integrity_status,
self.attestation_max_age_seconds,
diagnostics,
)
.map_err(anyhow::Error::msg)?;
}
}
let active_launcher_bootstrap_path =
active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE);
if bootstrap.is_some()
@@ -1986,6 +2106,24 @@ impl OfficialUpdateService {
"audit",
verification_progress_message(&final_verification_summary),
));
progress(OfficialUpdateProgress::new(
"publish",
"写入官方 distribution publication anchor",
));
write_official_distribution_publication_anchor_at(
&publish_plan.staging_path,
&publish_plan.id,
)
.map_err(anyhow::Error::msg)?;
write_official_distribution_attestation_at(
&publish_plan.staging_path,
&publish_plan.version_path,
&publish_plan.id,
"verified",
self.attestation_max_age_seconds,
Vec::new(),
)
.map_err(anyhow::Error::msg)?;
progress(OfficialUpdateProgress::new(
"snapshot",
format!("写入快照 {}", staging_snapshot_path.display()),
@@ -2151,6 +2289,77 @@ 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,
max_age_seconds: u64,
) -> 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_result =
OfficialResourcePullService::with_curl_command(&resource_root, &config.curl_command)
.with_proxy_config(config.curl_proxy.clone())
.verify_local_download_manifest();
let verification = match verification_result {
Ok(verification) => verification,
Err(error) => {
write_official_distribution_attestation_at(
&resource_root,
&resource_root,
&record.id,
"invalid",
max_age_seconds,
vec![format!("本地 manifest 验证失败:{error}")],
)
.map_err(anyhow::Error::msg)?;
return Err(anyhow::anyhow!(
"本地 manifest 验证失败,已立即使当前 distribution attestation 失效:{error}"
));
}
};
let diagnostics = verification
.items
.iter()
.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,
&resource_root,
&record.id,
integrity_status,
max_age_seconds,
diagnostics,
)
.map_err(anyhow::Error::msg)
}
fn run_post_sync_resource_handoff(
previous_resource_root: Option<&Path>,
current_resource_root: &Path,
@@ -3192,6 +3401,8 @@ fn copy_tree_no_symlink(
| OFFICIAL_VERSIONS_DIR
| OFFICIAL_CURRENT_LINK
| ".official-sync.lock"
| ".cas-owner-scope"
| OFFICIAL_DISTRIBUTION_PUBLICATION_FILE
) {
continue;
}
@@ -3221,7 +3432,15 @@ fn copy_tree_no_symlink(
continue;
}
}
if let Err(_error) = fs::hard_link(&source_path, &destination_path) {
if is_release_local_mutable_state(&source_path) {
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
format!(
"复制官方 release mutable state 失败 {} -> {}{copy_error}",
source_path.display(),
destination_path.display()
)
})?;
} else if let Err(_error) = fs::hard_link(&source_path, &destination_path) {
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
format!(
"复制官方资源到 staging 失败 {} -> {}{copy_error}",
@@ -3240,6 +3459,29 @@ fn copy_tree_no_symlink(
Ok(())
}
fn is_release_local_mutable_state(path: &Path) -> bool {
matches!(
path.file_name().and_then(|name| name.to_str()),
Some(
OFFICIAL_DOWNLOAD_MANIFEST_FILE
| OFFICIAL_SYNC_SNAPSHOT_FILE
| "official-parse-cache.json"
| "official-textunit-index.json"
| "official-textunit-tasks.json"
| "crowdin-textunit-queue.json"
| "official-resource-changes.json"
| "crowdin-translation-handoff.json"
| "translation-tasks.sqlite"
| "translation-tasks.sqlite-wal"
| "translation-tasks.sqlite-shm"
| "translation-handoff.json"
| OFFICIAL_LAUNCHER_BOOTSTRAP_FILE
| OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE
| "official-cas-reuse-references.json"
)
)
}
#[cfg(unix)]
fn switch_current_symlink(
root: &Path,
@@ -4275,21 +4517,22 @@ fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result<Patc
}
#[derive(Debug)]
struct OfficialUpdateLock {
pub(crate) struct OfficialUpdateLock {
path: PathBuf,
}
impl OfficialUpdateLock {
fn acquire(config: &OfficialUpdateConfig) -> anyhow::Result<Self> {
validate_output_root(&config.output_root).map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(&config.output_root, "资源输出目录")
.map_err(anyhow::Error::msg)?;
fs::create_dir_all(&config.output_root)?;
ensure_safe_directory_path(&config.output_root, "资源输出目录")
.map_err(anyhow::Error::msg)?;
let path = config.lock_path();
ensure_safe_file_target(&config.output_root, &path, "官方同步锁")
.map_err(anyhow::Error::msg)?;
Self::acquire_output_root(&config.output_root)
}
pub(crate) fn acquire_output_root(output_root: &Path) -> anyhow::Result<Self> {
validate_output_root(output_root).map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(output_root, "资源输出目录").map_err(anyhow::Error::msg)?;
fs::create_dir_all(output_root)?;
ensure_safe_directory_path(output_root, "资源输出目录").map_err(anyhow::Error::msg)?;
let path = output_root.join(".official-sync.lock");
ensure_safe_file_target(output_root, &path, "官方同步锁").map_err(anyhow::Error::msg)?;
for attempt in 0..=1 {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
@@ -4327,6 +4570,12 @@ impl OfficialUpdateLock {
}
}
pub(crate) fn acquire_official_output_lock(
output_root: &Path,
) -> anyhow::Result<OfficialUpdateLock> {
OfficialUpdateLock::acquire_output_root(output_root)
}
impl Drop for OfficialUpdateLock {
fn drop(&mut self) {
let expected = std::process::id().to_string();
@@ -4412,6 +4661,60 @@ fn process_exists(_pid: u32) -> bool {
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn staging_copy_does_not_hard_link_mutable_translation_state() {
use std::os::unix::fs::MetadataExt;
let temp = tempfile::TempDir::new().unwrap();
let source = temp.path().join("old");
let destination = temp.path().join("new");
fs::create_dir_all(&source).unwrap();
let mutable_files = [
OFFICIAL_DOWNLOAD_MANIFEST_FILE,
OFFICIAL_SYNC_SNAPSHOT_FILE,
"official-parse-cache.json",
"official-textunit-index.json",
"official-textunit-tasks.json",
"crowdin-textunit-queue.json",
"official-resource-changes.json",
"crowdin-translation-handoff.json",
"translation-tasks.sqlite",
"translation-tasks.sqlite-wal",
"translation-tasks.sqlite-shm",
"translation-handoff.json",
OFFICIAL_LAUNCHER_BOOTSTRAP_FILE,
OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE,
"official-cas-reuse-references.json",
];
for (index, name) in mutable_files.iter().enumerate() {
fs::write(source.join(name), format!("old-{index}")).unwrap();
}
fs::write(source.join("immutable.bundle"), b"payload").unwrap();
copy_tree_no_symlink(&source, &destination, false).unwrap();
for (index, name) in mutable_files.iter().enumerate() {
assert_ne!(
fs::metadata(source.join(name)).unwrap().ino(),
fs::metadata(destination.join(name)).unwrap().ino(),
"mutable state unexpectedly hard-linked: {name}"
);
fs::write(destination.join(name), format!("new-{index}")).unwrap();
assert_eq!(
fs::read(source.join(name)).unwrap(),
format!("old-{index}").as_bytes(),
"historical mutable state changed: {name}"
);
}
assert_eq!(
fs::metadata(source.join("immutable.bundle")).unwrap().ino(),
fs::metadata(destination.join("immutable.bundle"))
.unwrap()
.ino()
);
}
#[test]
fn missing_app_version_carries_input_error_code() {
// 未启用 auto-discover 且未传 app-version:配置校验失败应携带
@@ -4772,6 +5075,227 @@ mod tests {
assert_eq!(read_version_state(&path).unwrap(), Some(state));
}
#[cfg(unix)]
#[test]
fn publication_attestation_keeps_canonical_root_across_staging_rename() {
use crate::official_download::{
official_distribution_max_age_seconds, write_official_distribution_attestation_at,
write_official_distribution_publication_anchor_at, OfficialDownloadManifest,
OfficialDownloadManifestEntry,
};
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("official");
let layout = OfficialPublishLayout::new(&root);
let staging = layout.staging_dir.join("release-a");
let version = layout.versions_dir.join("release-a");
fs::create_dir_all(&staging).unwrap();
fs::create_dir_all(&layout.versions_dir).unwrap();
let payload = b"official";
fs::write(staging.join("data.bin"), payload).unwrap();
let url = "https://example.invalid/data.bin".to_string();
let mut manifest = OfficialDownloadManifest {
entries: [(
url.clone(),
OfficialDownloadManifestEntry {
url: url.clone(),
destination: "data.bin".to_string(),
bytes: payload.len() as u64,
blake3: blake3::hash(payload).to_hex().to_string(),
},
)]
.into_iter()
.collect(),
..OfficialDownloadManifest::default()
};
manifest.destination_index = [("data.bin".to_string(), url)].into_iter().collect();
manifest.distribution_mapping_identity =
Some(crate::official_download::official_distribution_mapping_identity(&manifest));
fs::write(
staging.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE),
serde_json::to_vec(&manifest).unwrap(),
)
.unwrap();
write_official_distribution_publication_anchor_at(&staging, "release-a").unwrap();
let max_age = official_distribution_max_age_seconds(3600, 60);
let before_publish = write_official_distribution_attestation_at(
&staging,
&version,
"release-a",
"verified",
max_age,
Vec::new(),
)
.unwrap();
assert_eq!(before_publish.resource_root, version);
assert_eq!(before_publish.verification_generation, 1);
let plan = OfficialPublishPlan {
id: "release-a".to_string(),
staging_path: staging,
version_path: version.clone(),
reuse_existing_staging: false,
};
let published = layout.publish(&plan).unwrap();
assert_eq!(published, version);
let after_rename =
crate::official_download::read_official_distribution_attestation_at(&version)
.unwrap()
.unwrap();
assert_eq!(after_rename.resource_root, version);
assert_eq!(
after_rename.verification_generation,
before_publish.verification_generation
);
let snapshot_path = version.join(OFFICIAL_SYNC_SNAPSHOT_FILE);
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
write_snapshot(&snapshot_path, &snapshot).unwrap();
let state = OfficialVersionState {
current_completed_version: Some(OfficialVersionRecord {
id: "release-a".to_string(),
app_version: snapshot.app_version.clone(),
bundle_version: snapshot.bundle_version.clone(),
addressables_root: snapshot.addressables_root.clone(),
resource_root: version.clone(),
snapshot_path,
staging_path: None,
version_path: Some(version.clone()),
started_unix_seconds: Some(1),
completed_unix_seconds: Some(2),
}),
..OfficialVersionState::default()
};
write_version_state(&root.join(OFFICIAL_VERSION_STATE_FILE), &state).unwrap();
assert_eq!(
fs::read_link(root.join(OFFICIAL_CURRENT_LINK)).unwrap(),
Path::new(OFFICIAL_VERSIONS_DIR).join("release-a")
);
assert_eq!(
state
.current_completed_version
.as_ref()
.unwrap()
.resource_root,
after_rename.resource_root
);
let report = crate::release_ops::build_official_distribution_attestation(&root).unwrap();
assert!(report.available);
assert!(report.ready);
assert_eq!(report.release_id, "release-a");
assert_eq!(report.resource_root, version.display().to_string());
assert_eq!(report.verification_generation, 1);
// Keep this assertion explicit: current is the only pointer used by
// the read path, and the attestation never stores the staging path.
assert!(!after_rename.resource_root.starts_with(layout.staging_dir));
}
#[test]
fn failed_local_verification_invalidates_previous_ready_generation() {
use crate::official_download::{
official_distribution_max_age_seconds, write_official_distribution_attestation_at,
write_official_distribution_publication_anchor_at, OfficialDownloadManifest,
OfficialDownloadManifestEntry,
};
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("official");
let version = root.join(OFFICIAL_VERSIONS_DIR).join("release-a");
fs::create_dir_all(&version).unwrap();
let payload = b"official";
let url = "https://example.invalid/data.bin".to_string();
let mut manifest = OfficialDownloadManifest {
entries: [(
url.clone(),
OfficialDownloadManifestEntry {
url,
destination: "data.bin".to_string(),
bytes: payload.len() as u64,
blake3: blake3::hash(payload).to_hex().to_string(),
},
)]
.into_iter()
.collect(),
..OfficialDownloadManifest::default()
};
manifest.destination_index = [(
"data.bin".to_string(),
"https://example.invalid/data.bin".to_string(),
)]
.into_iter()
.collect();
manifest.distribution_mapping_identity =
Some(crate::official_download::official_distribution_mapping_identity(&manifest));
fs::write(
version.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE),
serde_json::to_vec(&manifest).unwrap(),
)
.unwrap();
fs::write(version.join("data.bin"), payload).unwrap();
write_official_distribution_publication_anchor_at(&version, "release-a").unwrap();
let max_age = official_distribution_max_age_seconds(3600, 60);
let initial = write_official_distribution_attestation_at(
&version,
&version,
"release-a",
"verified",
max_age,
Vec::new(),
)
.unwrap();
symlink(
Path::new(OFFICIAL_VERSIONS_DIR).join("release-a"),
root.join(OFFICIAL_CURRENT_LINK),
)
.unwrap();
let snapshot_path = version.join(OFFICIAL_SYNC_SNAPSHOT_FILE);
write_snapshot(
&snapshot_path,
&OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None),
)
.unwrap();
write_version_state(
&root.join(OFFICIAL_VERSION_STATE_FILE),
&OfficialVersionState {
current_completed_version: Some(OfficialVersionRecord {
id: "release-a".to_string(),
app_version: "app".to_string(),
bundle_version: None,
addressables_root: "root".to_string(),
resource_root: version.clone(),
snapshot_path,
staging_path: None,
version_path: Some(version.clone()),
started_unix_seconds: Some(1),
completed_unix_seconds: Some(2),
}),
..OfficialVersionState::default()
},
)
.unwrap();
fs::write(version.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE), b"{malformed").unwrap();
let config = OfficialUpdateConfig {
output_root: root,
..OfficialUpdateConfig::default()
};
assert!(verify_and_record_official_distribution_attestation(&config, max_age).is_err());
let invalid = crate::official_download::read_official_distribution_attestation_at(&version)
.unwrap()
.unwrap();
assert_eq!(
invalid.verification_generation,
initial.verification_generation + 1
);
assert_eq!(invalid.integrity_status, "invalid");
assert!(!invalid.ready);
assert!(invalid.verified_at.is_none());
}
#[test]
fn version_state_tracks_in_progress_success_and_failure() {
let temp = tempfile::TempDir::new().unwrap();
+10 -1
View File
@@ -78,6 +78,10 @@ pub enum ReleaseFlowStatusCode {
/// A localized release is published and matches the current official release.
#[serde(rename = "localized.published")]
LocalizedPublished,
/// A localized current release exists but its manifest or published bytes
/// fail read-only integrity verification.
#[serde(rename = "localized.degraded")]
LocalizedDegraded,
/// Distribution cannot serve a usable release for the observed channel.
#[serde(rename = "distribution.blocked")]
DistributionBlocked,
@@ -113,6 +117,7 @@ impl ReleaseFlowStatusCode {
Self::LocalizedPending => "localized.pending",
Self::LocalizedStale => "localized.stale",
Self::LocalizedPublished => "localized.published",
Self::LocalizedDegraded => "localized.degraded",
Self::DistributionBlocked => "distribution.blocked",
Self::DistributionReady => "distribution.ready",
}
@@ -146,6 +151,7 @@ impl ReleaseFlowStatusCode {
Self::LocalizedPending => "pending",
Self::LocalizedStale => "stale",
Self::LocalizedPublished => "published",
Self::LocalizedDegraded => "degraded",
Self::DistributionBlocked => "blocked",
Self::DistributionReady => "ready",
}
@@ -177,6 +183,7 @@ impl ReleaseFlowStatusCode {
"localized.pending" => Self::LocalizedPending,
"localized.stale" => Self::LocalizedStale,
"localized.published" => Self::LocalizedPublished,
"localized.degraded" => Self::LocalizedDegraded,
"distribution.blocked" => Self::DistributionBlocked,
"distribution.ready" => Self::DistributionReady,
_ => return None,
@@ -208,7 +215,8 @@ impl ReleaseFlowStatusCode {
Self::LocalizedBlockedOfficial
| Self::LocalizedPending
| Self::LocalizedStale
| Self::LocalizedPublished => "localized_publish",
| Self::LocalizedPublished
| Self::LocalizedDegraded => "localized_publish",
Self::DistributionBlocked | Self::DistributionReady => "distribution",
}
}
@@ -300,6 +308,7 @@ mod tests {
ReleaseFlowStatusCode::LocalizedPending,
ReleaseFlowStatusCode::LocalizedStale,
ReleaseFlowStatusCode::LocalizedPublished,
ReleaseFlowStatusCode::LocalizedDegraded,
ReleaseFlowStatusCode::DistributionBlocked,
ReleaseFlowStatusCode::DistributionReady,
];
File diff suppressed because it is too large Load Diff
+377
View File
@@ -0,0 +1,377 @@
//! Shared, deliberately small SQLite schema-migration primitives.
//!
//! Component owners still define their own schema fingerprints and migration
//! steps. This module only owns the read-only preflight, schema snapshot, and
//! writer-lock mechanics shared by the long-lived SQLite stores.
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{Row, SqliteConnection, SqlitePool};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
pub const SCHEMA_MIGRATIONS_TABLE: &str = "schema_migrations";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SqliteColumn {
pub data_type: String,
pub not_null: bool,
pub default_value: Option<String>,
pub primary_key: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SqliteSchemaSnapshot {
/// Non-internal SQLite objects, including tables, indexes, views, and
/// triggers. Internal `sqlite_autoindex_*` objects are omitted.
pub objects: BTreeSet<(String, String)>,
pub tables: BTreeMap<String, BTreeMap<String, SqliteColumn>>,
pub indexes: BTreeMap<String, BTreeMap<String, Vec<String>>>,
pub component_version: Option<i64>,
}
impl SqliteSchemaSnapshot {
pub fn is_empty(&self) -> bool {
self.objects.is_empty()
}
}
#[derive(Debug, Clone, Copy)]
pub struct ExpectedColumn<'a> {
pub name: &'a str,
pub data_type: &'a str,
pub not_null: bool,
pub default_value: Option<&'a str>,
pub primary_key: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct ExpectedTable<'a> {
pub name: &'a str,
pub columns: &'a [ExpectedColumn<'a>],
}
#[derive(Debug, Clone, Copy)]
pub struct ExpectedIndex<'a> {
pub table: &'a str,
pub name: &'a str,
pub columns: &'a [&'a str],
}
/// Opens an existing database with SQLite's read-only flag and snapshots its
/// schema before a writable connection can perform any mutation.
pub async fn read_only_preflight(
path: &Path,
component: &str,
) -> Result<SqliteSchemaSnapshot, sqlx::Error> {
let has_wal_sidecar =
sidecar_path(path, "-wal").exists() || sidecar_path(path, "-shm").exists();
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))?
.read_only(true)
.create_if_missing(false)
// A cleanly closed WAL database has all committed pages in the main
// file. Immutable read-only mode prevents SQLite from creating a new
// `-shm` sidecar during future-schema rejection. Live WAL sidecars
// must remain visible to the preflight reader.
.immutable(!has_wal_sidecar)
.busy_timeout(Duration::from_secs(30));
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await?;
let snapshot = {
let mut connection = pool.acquire().await?;
snapshot_connection(&mut connection, component).await
};
pool.close().await;
snapshot
}
/// Connects a writable single-connection pool, retrying the SQLite-specific
/// exclusive lock needed when a connection switches an existing database to
/// WAL mode. SQLite's busy timeout cannot wait for that PRAGMA, so the retry
/// belongs around connection establishment rather than only around writes.
pub async fn connect_writable_pool(
options: SqliteConnectOptions,
) -> Result<SqlitePool, sqlx::Error> {
const MAX_ATTEMPTS: usize = 32;
for attempt in 0..=MAX_ATTEMPTS {
match SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options.clone())
.await
{
Ok(pool) => return Ok(pool),
Err(error) if attempt < MAX_ATTEMPTS && is_sqlite_lock_error(&error) => {
let delay_millis = (25 * (attempt as u64 + 1)).min(250);
tokio::time::sleep(Duration::from_millis(delay_millis)).await;
}
Err(error) => return Err(error),
}
}
unreachable!("SQLite connection retry loop always returns")
}
fn is_sqlite_lock_error(error: &sqlx::Error) -> bool {
error.to_string().contains("database is locked")
}
/// Snapshots the schema using an already-open connection. The caller may use
/// this both for read-only preflight and inside the migration transaction.
pub async fn snapshot_connection(
connection: &mut SqliteConnection,
component: &str,
) -> Result<SqliteSchemaSnapshot, sqlx::Error> {
let object_rows = sqlx::query(
"SELECT type, name FROM sqlite_master
WHERE name NOT LIKE 'sqlite_%'
ORDER BY type, name",
)
.fetch_all(&mut *connection)
.await?;
let mut objects = BTreeSet::new();
let mut table_names = BTreeSet::new();
for row in object_rows {
let object_type: String = row.try_get("type")?;
let name: String = row.try_get("name")?;
if object_type == "table" {
table_names.insert(name.clone());
}
objects.insert((object_type, name));
}
let mut tables = BTreeMap::new();
let mut indexes = BTreeMap::new();
for table in table_names {
let quoted_table = quote_identifier(&table);
let column_rows = sqlx::query(&format!("PRAGMA table_info({quoted_table})"))
.fetch_all(&mut *connection)
.await?;
let mut columns = BTreeMap::new();
for row in column_rows {
let name: String = row.try_get("name")?;
let data_type: String = row.try_get("type")?;
let not_null: i64 = row.try_get("notnull")?;
let default_value: Option<String> = row.try_get("dflt_value")?;
let primary_key: i64 = row.try_get("pk")?;
columns.insert(
name,
SqliteColumn {
data_type,
not_null: not_null != 0,
default_value,
primary_key: primary_key != 0,
},
);
}
tables.insert(table.clone(), columns);
let index_rows = sqlx::query(&format!("PRAGMA index_list({quoted_table})"))
.fetch_all(&mut *connection)
.await?;
let mut table_indexes = BTreeMap::new();
for row in index_rows {
let index_name: String = row.try_get("name")?;
if index_name.starts_with("sqlite_autoindex_") {
continue;
}
let quoted_index = quote_identifier(&index_name);
let index_columns = sqlx::query(&format!("PRAGMA index_info({quoted_index})"))
.fetch_all(&mut *connection)
.await?;
let mut columns = Vec::new();
for index_column in index_columns {
let sequence: i64 = index_column.try_get("seqno")?;
let name: Option<String> = index_column.try_get("name")?;
if sequence < 0 {
continue;
}
let name = name.ok_or_else(|| {
sqlx::Error::Protocol(format!(
"SQLite index {index_name} has an unnamed column"
))
})?;
columns.push((sequence, name));
}
columns.sort_by_key(|(sequence, _)| *sequence);
table_indexes.insert(
index_name,
columns
.into_iter()
.map(|(_, name)| name)
.collect::<Vec<_>>(),
);
}
if !table_indexes.is_empty() {
indexes.insert(table, table_indexes);
}
}
let component_version = if tables.contains_key(SCHEMA_MIGRATIONS_TABLE) {
sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1")
.bind(component)
.fetch_optional(&mut *connection)
.await?
} else {
None
};
Ok(SqliteSchemaSnapshot {
objects,
tables,
indexes,
component_version,
})
}
/// Starts a real SQLite writer transaction. `BEGIN IMMEDIATE` serializes DDL
/// migration writers instead of allowing two preflight results to race.
pub async fn begin_immediate(
pool: &SqlitePool,
) -> Result<sqlx::Transaction<'static, sqlx::Sqlite>, sqlx::Error> {
pool.begin_with("BEGIN IMMEDIATE").await
}
pub async fn write_component_version(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
component: &str,
version: u32,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO schema_migrations(component, version) VALUES (?1, ?2)
ON CONFLICT(component) DO UPDATE SET version = excluded.version",
)
.bind(component)
.bind(i64::from(version))
.execute(&mut **transaction)
.await?;
Ok(())
}
pub async fn create_schema_migrations_table(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
) -> Result<(), sqlx::Error> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS schema_migrations (
component TEXT PRIMARY KEY NOT NULL,
version INTEGER NOT NULL CHECK(version >= 1)
)",
)
.execute(&mut **transaction)
.await?;
Ok(())
}
pub fn matches_fingerprint(
snapshot: &SqliteSchemaSnapshot,
expected_tables: &[ExpectedTable<'_>],
expected_indexes: &[ExpectedIndex<'_>],
) -> bool {
let expected_table_names = expected_tables
.iter()
.map(|table| table.name)
.collect::<BTreeSet<_>>();
let actual_table_names: BTreeSet<&str> = snapshot.tables.keys().map(String::as_str).collect();
if actual_table_names != expected_table_names {
return false;
}
let expected_object_names = expected_tables
.iter()
.map(|table| ("table", table.name))
.chain(expected_indexes.iter().map(|index| ("index", index.name)))
.collect::<BTreeSet<_>>();
let actual_object_names = snapshot
.objects
.iter()
.map(|(object_type, name)| (object_type.as_str(), name.as_str()))
.collect::<BTreeSet<_>>();
if actual_object_names != expected_object_names {
return false;
}
for table in expected_tables {
let Some(actual_columns) = snapshot.tables.get(table.name) else {
return false;
};
if actual_columns.len() != table.columns.len() {
return false;
}
for expected in table.columns {
let Some(actual) = actual_columns.get(expected.name) else {
return false;
};
if actual.data_type.to_ascii_uppercase() != expected.data_type
|| actual.not_null != expected.not_null
|| actual.primary_key != expected.primary_key
|| normalize_default(actual.default_value.as_deref())
!= normalize_default(expected.default_value)
{
return false;
}
}
}
let expected_indexes = expected_indexes
.iter()
.map(|index| {
(
index.table.to_string(),
index.name.to_string(),
index
.columns
.iter()
.map(|column| (*column).to_string())
.collect::<Vec<_>>(),
)
})
.collect::<BTreeSet<_>>();
let actual_indexes = snapshot
.indexes
.iter()
.flat_map(|(table, indexes)| {
indexes
.iter()
.map(|(name, columns)| (table.clone(), name.clone(), columns.clone()))
})
.collect::<BTreeSet<_>>();
actual_indexes == expected_indexes
}
pub fn schema_migrations_table() -> ExpectedTable<'static> {
ExpectedTable {
name: SCHEMA_MIGRATIONS_TABLE,
columns: &[
ExpectedColumn {
name: "component",
data_type: "TEXT",
not_null: true,
default_value: None,
primary_key: true,
},
ExpectedColumn {
name: "version",
data_type: "INTEGER",
not_null: true,
default_value: None,
primary_key: false,
},
],
}
}
fn normalize_default(value: Option<&str>) -> Option<String> {
value.map(|value| value.trim().to_ascii_lowercase())
}
fn quote_identifier(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
fn sidecar_path(path: &Path, suffix: &str) -> std::path::PathBuf {
std::path::PathBuf::from(format!("{}{}", path.display(), suffix))
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+137
View File
@@ -968,6 +968,7 @@ async fn process_claimed_task(
}
} else {
bat_core::domain::GlossaryEvaluation {
qa_identity: String::new(),
constraints: Vec::new(),
diagnostics: Vec::new(),
blocked: false,
@@ -1521,6 +1522,7 @@ mod tests {
OfficialTextUnitTask, OfficialTextUnitTaskQueue, OfficialTextUnitTaskStatus,
OfficialTextUnitTaskSummary, OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION,
};
use bat_core::domain::TranslationMemoryMatchKind;
fn fixture_root() -> (tempfile::TempDir, OfficialTextUnitTaskQueue) {
let temp = tempfile::TempDir::new().unwrap();
@@ -1760,6 +1762,10 @@ mod tests {
.map(|qa| qa.status),
Some(bat_core::domain::GlossaryQaStatus::Pass)
);
assert!(task.translation_results[0]
.glossary_qa
.as_ref()
.is_some_and(|qa| !qa.qa_identity.is_empty()));
}
#[tokio::test]
@@ -1912,6 +1918,137 @@ mod tests {
);
}
#[tokio::test]
async fn worker_does_not_reuse_a_trusted_conflict() {
let (temp, queue) = fixture_root();
let textunit_index = index(temp.path());
crate::official_textunit_queue::write_textunit_task_queue_at(temp.path(), &queue).unwrap();
crate::official_parse::write_textunit_index_at(temp.path(), &textunit_index).unwrap();
let translation_memory_path = temp.path().join("translation-memory.sqlite");
let translation_memory = SqliteTranslationMemoryRepository::new(&translation_memory_path)
.await
.unwrap();
let unit = &textunit_index.units[0];
let source_context = translation_memory_context(
&unit.destination,
unit.archive_entry.as_deref(),
unit.serialized_file.as_deref(),
unit.path_id,
unit.class_id,
unit.field_path.as_deref(),
unit.format.as_deref(),
unit.asset_name.as_deref(),
unit.text_source_kind.as_deref(),
&unit.context,
);
let mut trusted_ids = Vec::new();
for (release, translated) in [("release-1", "旧译文一"), ("release-2", "旧译文二")]
{
let entry = translation_memory
.upsert_candidate(TranslationMemoryDraft {
source_text: unit.source_text.clone(),
source_context: source_context.clone(),
translated_text: translated.to_string(),
translation_source_kind: TranslationMemorySourceKind::Manual,
official_release_id: release.to_string(),
source_trace: TranslationMemorySourceTrace {
official_release_id: release.to_string(),
unit_id: Some(unit.id.clone()),
task_id: Some(queue.tasks[0].task_id.clone()),
destination: Some(unit.destination.clone()),
archive_entry: unit.archive_entry.clone(),
serialized_file: unit.serialized_file.clone(),
path_id: unit.path_id,
class_id: unit.class_id,
field_path: unit.field_path.clone(),
format: unit.format.clone(),
asset_name: unit.asset_name.clone(),
text_source_kind: unit.text_source_kind.clone(),
source_url: Some(unit.source_url.clone()),
},
provider: None,
provider_run_id: None,
observed_unix_seconds: 1,
})
.await
.unwrap();
sqlx::query(
"UPDATE translation_memory
SET trust_status = 'trusted', trusted_unix_seconds = 100,
trusted_by = 'legacy-reviewer', trusted_reason = 'legacy fixture'
WHERE record_id = ?1",
)
.bind(&entry.record_id)
.execute(&translation_memory.pool)
.await
.unwrap();
trusted_ids.push(entry.record_id);
}
assert_eq!(
translation_memory
.find_matches(&unit.source_text, &source_context, 10)
.await
.unwrap()
.iter()
.filter(|item| item.match_kind == TranslationMemoryMatchKind::TrustedConflict)
.count(),
2
);
let fixture = temp.path().join("conflict-mock.json");
std::fs::write(
&fixture,
serde_json::to_vec(&serde_json::json!({
"schema_version": 1,
"translations": {
"direct:bundle#unit:0": "provider-after-conflict",
"direct:bundle#unit:1": "translated-by-provider"
}
}))
.unwrap(),
)
.unwrap();
let config = TranslationWorkerConfig {
fixture_path: Some(fixture),
translation_memory_path: Some(translation_memory_path),
concurrency: 1,
retry_backoff: Duration::ZERO,
..TranslationWorkerConfig::default()
};
let report = run_translation_worker_at(temp.path(), &config)
.await
.unwrap();
assert_eq!(report.translation_memory_hit_count, 0);
assert_eq!(report.provider_unit_count, 2);
assert_eq!(report.completed_count, 1);
let task_repository = SqliteTranslationTaskRepository::open(
SqliteTranslationTaskRepository::repository_path(temp.path()),
)
.await
.unwrap();
let task = task_repository.find(&queue.tasks[0].task_id).await.unwrap();
assert_eq!(task.translation_results.len(), 2);
assert!(task
.translation_results
.iter()
.all(|result| result.source_kind == TranslationTaskResultSourceKind::Provider));
assert!(task.translation_results[0]
.translation_memory_record_id
.is_none());
for record_id in trusted_ids {
assert_eq!(
translation_memory
.find(&record_id)
.await
.unwrap()
.trust_status,
bat_core::domain::TranslationMemoryTrustStatus::Trusted
);
}
}
#[tokio::test]
async fn worker_retains_tm_hits_when_provider_fails_for_remaining_units() {
let (temp, queue) = fixture_root();
+324 -48
View File
@@ -16,7 +16,10 @@ use bat_assetbundle::{
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
};
use bat_core::domain::{GlossaryOverride, GlossaryQaReport};
use bat_core::domain::{
validate_glossary_override as validate_core_glossary_override, GlossaryOverride,
GlossaryQaReport,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
@@ -333,9 +336,8 @@ pub fn set_translation_checked_with_glossary_path(
.as_ref()
.map(|glossary| evaluate_glossary_entry(glossary, &current, &translated_text))
.transpose()?;
if let Some(qa) = qa.as_ref().filter(|qa| qa.status.is_blocked()) {
validate_glossary_override(glossary_override.as_ref())?;
let _ = qa;
if let Some(qa) = qa.as_ref() {
validate_current_glossary_qa(qa, glossary_override.as_ref())?;
} else if glossary_override.is_some() {
return Err(anyhow::anyhow!(
"Glossary override 只能用于存在 blocking QA 的译文"
@@ -464,17 +466,19 @@ pub fn validate_translation_workbench_with_glossary_path(
}
if let Some(glossary) = glossary.as_ref() {
let qa = evaluate_glossary_entry(glossary, current, translated_text)?;
if qa.status.is_blocked() {
validate_glossary_override(entry.glossary_override.as_ref())?;
}
validate_current_glossary_qa(&qa, entry.glossary_override.as_ref())?;
} else if entry.glossary_override.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 存在 Glossary override,但当前没有 Glossary 数据库",
entry.id
));
}
changed_entries += 1;
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
let is_publishable = entry.archive_entry.is_none()
&& matches!(
source_kind.as_deref(),
Some("textasset" | "typetreefield" | "managedreferencefield")
);
let is_publishable = matches!(
source_kind.as_deref(),
Some("textasset" | "typetreefield" | "managedreferencefield")
);
if is_publishable {
let serialized_file = entry
.serialized_file
@@ -560,33 +564,42 @@ fn evaluate_glossary_entry(
.map_err(|error| anyhow::anyhow!("执行 Glossary QA 失败:{error}"))
}
fn validate_glossary_override(glossary_override: Option<&GlossaryOverride>) -> anyhow::Result<()> {
let Some(glossary_override) = glossary_override else {
return Err(anyhow::anyhow!(
"Glossary QA blocked;需要 reviewer、reason 和 provenance 显式确认"
));
};
if glossary_override.reviewer.trim().is_empty()
|| glossary_override.reason.trim().is_empty()
|| glossary_override.provenance.trim().is_empty()
|| glossary_override.confirmed_unix_seconds == 0
{
return Err(anyhow::anyhow!(
"Glossary override 的 reviewer、reason、provenance 和 confirmed_unix_seconds 必须有效"
));
fn validate_current_glossary_qa(
qa: &GlossaryQaReport,
glossary_override: Option<&GlossaryOverride>,
) -> anyhow::Result<()> {
if qa.status.is_blocked() {
validate_core_glossary_override(qa, glossary_override)
.map_err(|error| anyhow::anyhow!("{error}"))?;
} else if glossary_override.is_some() {
return Err(anyhow::anyhow!("Glossary override 只能用于 blocking QA"));
}
Ok(())
}
/// Converts reviewed entries to localized patch operations supported by the
/// current UnityFS write layer.
///
/// ZIP-inner bundles are intentionally rejected here because they require a
/// separate archive rewrite boundary.
/// current UnityFS write layer and its ZIP rewrite boundary.
pub fn localized_patch_operations(
resource_root: &Path,
workbench: &TranslationWorkbench,
) -> anyhow::Result<Vec<LocalizedPatchInput>> {
localized_patch_operations_with_glossary_path(resource_root, workbench, None)
}
/// Converts reviewed entries to patch operations using QA recomputed from the
/// current Glossary. The recomputed report is the one persisted to the
/// localized release manifest.
pub fn localized_patch_operations_with_glossary_path(
resource_root: &Path,
workbench: &TranslationWorkbench,
configured_glossary_path: Option<&Path>,
) -> anyhow::Result<Vec<LocalizedPatchInput>> {
validate_translation_workbench_with_glossary_path(
resource_root,
&workbench.official_release_id,
workbench,
configured_glossary_path,
)?;
let index = read_textunit_index_at(resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?;
@@ -595,6 +608,10 @@ pub fn localized_patch_operations(
.iter()
.map(|unit| (unit.id.as_str(), unit))
.collect::<std::collections::HashMap<_, _>>();
let glossary_path = configured_glossary_path
.map(PathBuf::from)
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
let glossary = open_glossary_if_present(&glossary_path)?;
let mut seen = BTreeSet::new();
let mut operations = Vec::new();
@@ -609,6 +626,18 @@ pub fn localized_patch_operations(
if translated_text == &entry.source_text {
continue;
}
let current_qa = glossary
.as_ref()
.map(|glossary| evaluate_glossary_entry(glossary, current, translated_text))
.transpose()?;
if let Some(qa) = current_qa.as_ref() {
validate_current_glossary_qa(qa, entry.glossary_override.as_ref())?;
} else if entry.glossary_override.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 存在 Glossary override,但当前没有 Glossary 数据库",
entry.id
));
}
let Some(serialized_file) = entry.serialized_file.clone() else {
return Err(anyhow::anyhow!(
"TextUnit {} 没有 serialized_file,当前不能生成重打包 patch",
@@ -621,16 +650,11 @@ pub fn localized_patch_operations(
entry.id
));
};
if entry.archive_entry.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 位于 zip archive entry,当前 publish-localized 不支持直接修改 zip 内 bundle",
entry.id
));
}
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
let field_path = entry.field_path.clone();
if !seen.insert((
entry.destination.clone(),
entry.archive_entry.clone(),
serialized_file.clone(),
path_id,
field_path.clone(),
@@ -640,7 +664,11 @@ pub fn localized_patch_operations(
entry.id
));
}
let metadata = Some(localized_patch_metadata(entry));
let metadata = Some(localized_patch_metadata(
entry,
current_qa.as_ref(),
entry.glossary_override.as_ref(),
));
match source_kind.as_deref() {
Some("textasset") => {
let mut patch = TextAssetPatch::new(
@@ -651,6 +679,7 @@ pub fn localized_patch_operations(
patch.expected_name = entry.asset_name.clone();
operations.push(LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch {
bundle_path: entry.destination.clone(),
archive_entry: entry.archive_entry.clone(),
text_asset: patch,
metadata,
}));
@@ -665,6 +694,7 @@ pub fn localized_patch_operations(
operations.push(LocalizedPatchInput::StringField(
LocalizedStringFieldPatch {
bundle_path: entry.destination.clone(),
archive_entry: entry.archive_entry.clone(),
string_field: StringFieldPatch {
serialized_file_path: serialized_file,
path_id,
@@ -706,6 +736,8 @@ pub fn localized_text_asset_patches(
.iter()
.map(|unit| (unit.id.as_str(), unit))
.collect::<std::collections::HashMap<_, _>>();
let glossary =
open_glossary_if_present(&SqliteGlossaryRepository::repository_path(resource_root))?;
let mut seen = BTreeSet::new();
let mut patches = Vec::new();
@@ -732,12 +764,6 @@ pub fn localized_text_asset_patches(
entry.id
));
};
if entry.archive_entry.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 位于 zip archive entry,当前 publish-localized 不支持直接修改 zip 内 bundle",
entry.id
));
}
if normalized_text_source_kind(entry.text_source_kind.as_deref()).as_deref()
!= Some("textasset")
{
@@ -746,7 +772,12 @@ pub fn localized_text_asset_patches(
entry.id
));
}
if !seen.insert((entry.destination.clone(), serialized_file.clone(), path_id)) {
if !seen.insert((
entry.destination.clone(),
entry.archive_entry.clone(),
serialized_file.clone(),
path_id,
)) {
return Err(anyhow::anyhow!(
"翻译工作台包含重复 patch 目标:{}",
entry.id
@@ -758,10 +789,27 @@ pub fn localized_text_asset_patches(
translated_text.as_bytes().to_vec(),
);
patch.expected_name = entry.asset_name.clone();
let current_qa = glossary
.as_ref()
.map(|glossary| evaluate_glossary_entry(glossary, current, translated_text))
.transpose()?;
if let Some(qa) = current_qa.as_ref() {
validate_current_glossary_qa(qa, entry.glossary_override.as_ref())?;
} else if entry.glossary_override.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 存在 Glossary override,但当前没有 Glossary 数据库",
entry.id
));
}
patches.push(LocalizedTextAssetPatch {
bundle_path: entry.destination.clone(),
archive_entry: entry.archive_entry.clone(),
text_asset: patch,
metadata: Some(localized_patch_metadata(entry)),
metadata: Some(localized_patch_metadata(
entry,
current_qa.as_ref(),
entry.glossary_override.as_ref(),
)),
});
}
@@ -773,7 +821,11 @@ pub fn localized_text_asset_patches(
Ok(patches)
}
fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatchOperationMetadata {
fn localized_patch_metadata(
entry: &TranslationWorkbenchEntry,
glossary_qa: Option<&GlossaryQaReport>,
glossary_override: Option<&GlossaryOverride>,
) -> LocalizedPatchOperationMetadata {
LocalizedPatchOperationMetadata {
text_unit_id: entry.id.clone(),
source_text_blake3: blake3::hash(entry.source_text.as_bytes())
@@ -787,8 +839,8 @@ fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatch
.review_status
.clone()
.unwrap_or_else(|| "manual_reviewed".to_string()),
glossary_qa: entry.glossary_qa.clone(),
glossary_override: entry.glossary_override.clone(),
glossary_qa: glossary_qa.cloned(),
glossary_override: glossary_override.cloned(),
}
}
@@ -1251,6 +1303,172 @@ mod tests {
assert_eq!(report.unreviewed_entries, 0);
}
#[test]
fn checked_translation_rejects_override_after_glossary_change() {
let temp = tempfile::TempDir::new().unwrap();
let index = crate::official_parse::OfficialTextUnitIndex {
version: crate::official_parse::OFFICIAL_TEXTUNIT_INDEX_VERSION,
generated_unix_seconds: 1,
resource_root: temp.path().to_path_buf(),
summary: Default::default(),
units: vec![OfficialTextUnitIndexUnit {
id: "unit-1".to_string(),
parse_entry_key: "bundle".to_string(),
source_url: "https://example.invalid/bundle".to_string(),
destination: "bundles/test.bundle".to_string(),
archive_entry: None,
source_kind: crate::official_parse::OfficialParseSourceKind::DirectBundle,
unity_version: None,
source_text: "原文".to_string(),
serialized_file: Some("CAB-test".to_string()),
path_id: Some(7),
class_id: Some(49),
field_path: None,
field_offset: None,
field_byte_size: None,
format: Some("plain".to_string()),
text_source_kind: Some("text_asset".to_string()),
asset_name: Some("Story".to_string()),
context: Default::default(),
}],
errors: Vec::new(),
};
crate::official_parse::write_textunit_index_at(temp.path(), &index).unwrap();
let workbench_path = temp.path().join("workbench.json");
write_translation_workbench(&workbench_path, &workbench(temp.path())).unwrap();
let glossary_path = temp.path().join("glossary.sqlite");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let qa = runtime.block_on(async {
let glossary = SqliteGlossaryRepository::new(&glossary_path).await.unwrap();
glossary
.add(bat_core::domain::GlossaryTermDraft {
term_id: "term-source".to_string(),
definition: bat_core::domain::GlossaryTermSnapshot {
source_term: "原文".to_string(),
aliases: Vec::new(),
recommended_translation: "译文".to_string(),
allowed_translations: Vec::new(),
source_language: None,
target_language: None,
category: None,
priority: 1,
scope: BTreeMap::new(),
},
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
source: bat_core::domain::GlossarySourceRecord {
source_kind: bat_core::domain::GlossarySourceKind::Manual,
source_ref: None,
source_author: None,
source_note: None,
observed_unix_seconds: 1,
},
})
.await
.unwrap();
glossary
.review(
"term-source",
bat_core::domain::GlossaryReviewStatus::Approved,
"reviewer",
Some("approve".to_string()),
)
.await
.unwrap();
let context = crate::translation_memory::translation_memory_context(
"bundles/test.bundle",
None,
Some("CAB-test"),
Some(7),
Some(49),
None,
Some("plain"),
Some("Story"),
Some("text_asset"),
&BTreeMap::new(),
);
let evaluation = glossary.diagnose("原文", &context).await.unwrap();
(glossary, evaluation.check_translation("错误"))
});
let (glossary, qa) = qa;
let override_record = GlossaryOverride {
qa_identity: qa.qa_identity.clone(),
reviewer: "reviewer".to_string(),
reason: "manual review".to_string(),
provenance: "workbench".to_string(),
confirmed_unix_seconds: 1,
};
let updated = set_translation_checked_with_glossary_path(
temp.path(),
&workbench_path,
"unit-1",
"错误".to_string(),
Some(override_record.clone()),
Some(&glossary_path),
)
.unwrap();
assert_eq!(
updated
.glossary_qa
.as_ref()
.map(|report| report.qa_identity.as_str()),
Some(qa.qa_identity.as_str())
);
let loaded = read_translation_workbench(&workbench_path).unwrap();
let validation = validate_translation_workbench_with_glossary_path(
temp.path(),
"release-1",
&loaded,
Some(&glossary_path),
)
.unwrap();
assert_eq!(validation.status, "valid");
runtime.block_on(async {
let current = glossary.find("term-source").await.unwrap();
glossary
.update(
bat_core::domain::GlossaryTermDraft {
term_id: current.term_id,
definition: bat_core::domain::GlossaryTermSnapshot {
recommended_translation: "新译文".to_string(),
..current.definition
},
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
source: bat_core::domain::GlossarySourceRecord {
observed_unix_seconds: 2,
..current.source
},
},
"reviewer",
Some("change recommendation".to_string()),
)
.await
.unwrap();
glossary
.review(
"term-source",
bat_core::domain::GlossaryReviewStatus::Approved,
"reviewer",
Some("approve changed term".to_string()),
)
.await
.unwrap();
});
let error = set_translation_checked_with_glossary_path(
temp.path(),
&workbench_path,
"unit-1",
"错误".to_string(),
Some(override_record),
Some(&glossary_path),
)
.unwrap_err();
assert!(error.to_string().contains("qa_identity"));
}
#[test]
fn localized_operations_preserve_type_tree_field_traceability() {
let temp = tempfile::TempDir::new().unwrap();
@@ -1290,6 +1508,59 @@ mod tests {
entry.translated_text = Some("译文".to_string());
entry.translation_provider = Some("mock".to_string());
entry.provider_run_id = Some("run-1".to_string());
entry.glossary_qa = Some(GlossaryQaReport {
qa_identity: "stale-workbench-qa".to_string(),
status: bat_core::domain::GlossaryQaStatus::Pass,
constraints: Vec::new(),
diagnostics: Vec::new(),
});
let glossary_path = temp.path().join("glossary.sqlite");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let expected_qa = runtime.block_on(async {
let glossary = SqliteGlossaryRepository::new(&glossary_path).await.unwrap();
glossary
.add(bat_core::domain::GlossaryTermDraft {
term_id: "term-source".to_string(),
definition: bat_core::domain::GlossaryTermSnapshot {
source_term: "原文".to_string(),
aliases: Vec::new(),
recommended_translation: "译文".to_string(),
allowed_translations: Vec::new(),
source_language: None,
target_language: None,
category: None,
priority: 1,
scope: BTreeMap::new(),
},
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
source: bat_core::domain::GlossarySourceRecord {
source_kind: bat_core::domain::GlossarySourceKind::Manual,
source_ref: None,
source_author: None,
source_note: None,
observed_unix_seconds: 1,
},
})
.await
.unwrap();
glossary
.review(
"term-source",
bat_core::domain::GlossaryReviewStatus::Approved,
"reviewer",
Some("approve".to_string()),
)
.await
.unwrap();
glossary
.diagnose("原文", &BTreeMap::new())
.await
.unwrap()
.check_translation("译文")
});
let operations = localized_patch_operations(temp.path(), &workbench).unwrap();
assert_eq!(operations.len(), 1);
@@ -1305,6 +1576,11 @@ mod tests {
assert_eq!(metadata.text_unit_id, "unit-1");
assert_eq!(metadata.translation_provider.as_deref(), Some("mock"));
assert_eq!(metadata.provider_run_id.as_deref(), Some("run-1"));
assert_eq!(
metadata.glossary_qa.as_ref().map(|qa| qa.status),
Some(bat_core::domain::GlossaryQaStatus::Pass)
);
assert_eq!(metadata.glossary_qa.as_ref(), Some(&expected_qa));
}
other => panic!("unexpected localized operation: {other:?}"),
}
+163 -7
View File
@@ -56,6 +56,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/translation/handoff",
"/admin/translation/memory/summary",
"/admin/translation/memory/query",
"/admin/translation/memory/conflicts",
"/admin/translation/glossary/summary",
"/admin/translation/glossary/query",
"/admin/translation/glossary/diagnose",
@@ -78,6 +79,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/translation-worker-run",
"/admin/control/translation-proofread",
"/admin/control/translation-memory-confirm",
"/admin/control/translation-memory-resolve-conflict",
"/admin/control/translation-glossary-add",
"/admin/control/translation-glossary-update",
"/admin/control/translation-glossary-approve",
@@ -85,6 +87,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/translation-glossary-delete",
"/admin/control/localized-publish",
"/admin/control/localized-rollback",
"/admin/control/release-cleanup",
},
}
if r.Method == http.MethodHead {
@@ -132,6 +135,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
s.handleAdminTranslationMemoryConfirm(w, r)
return
}
if action == "translation-memory-resolve-conflict" {
s.handleAdminTranslationMemoryResolveConflict(w, r)
return
}
if strings.HasPrefix(action, "translation-glossary-") {
s.handleAdminGlossaryControl(w, r, action)
return
@@ -144,6 +151,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
s.handleAdminLocalizedRollback(w, r)
return
}
if action == "release-cleanup" {
s.handleAdminReleaseCleanup(w, r)
return
}
request, ok := decodeAdminControlRequest(w, r)
if !ok {
return
@@ -217,6 +228,34 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleAdminTranslationMemoryResolveConflict(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(TranslationMemoryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "translation_memory_backend_unavailable", "Rust bat Translation Memory backend is unavailable")
return
}
var params backendrpc.TranslationMemoryResolveConflictParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if err := validateTranslationMemoryResolveConflictParams(params); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_memory_params", err.Error())
return
}
result, err := backend.TranslationMemoryResolveConflict(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "translation-memory-resolve-conflict", err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: "translation-memory-resolve-conflict",
RPCMethod: "translation.memory.resolve_conflict",
Status: "accepted",
Result: result,
})
}
func (s *Server) handleAdminTranslationTaskUpdate(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(TranslationBackend)
if !ok || backend == nil {
@@ -619,6 +658,34 @@ func (s *Server) handleAdminLocalizedStatus(w http.ResponseWriter, r *http.Reque
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminReleaseCleanup(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
return
}
var params backendrpc.ReleaseCleanupParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if params.Execute && strings.TrimSpace(params.PlanID) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_release_cleanup_params", "execute cleanup requires plan_id from a dry run")
return
}
result, err := backend.ReleaseCleanup(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "release-cleanup", err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: "release-cleanup",
RPCMethod: "release.cleanup",
Status: "accepted",
Result: result,
})
}
func (s *Server) handleAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -983,6 +1050,37 @@ func (s *Server) handleAdminTranslationMemoryQuery(w http.ResponseWriter, r *htt
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminTranslationMemoryConflicts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(TranslationMemoryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "translation_memory_backend_unavailable", "Rust bat Translation Memory backend is unavailable")
return
}
params, err := translationMemoryConflictsParams(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_memory_query", err.Error())
return
}
result, err := backend.TranslationMemoryConflicts(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "translation-memory-conflicts", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminSchedules(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -1106,6 +1204,20 @@ func translationMemoryQueryParams(r *http.Request) (backendrpc.TranslationMemory
return params, nil
}
func translationMemoryConflictsParams(r *http.Request) (backendrpc.TranslationMemoryConflictsParams, error) {
params := backendrpc.TranslationMemoryConflictsParams{
TranslationMemoryPath: firstTrimmedQuery(r.URL.Query(), "translation_memory_path", "tm_path"),
}
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
limit, err := strconv.ParseUint(raw, 10, 64)
if err != nil || limit == 0 || limit > 1000 {
return backendrpc.TranslationMemoryConflictsParams{}, errors.New("limit must be in 1..=1000")
}
params.Limit = &limit
}
return params, nil
}
func parseTextUnitQueryParams(r *http.Request) (backendrpc.TextUnitQueryParams, error) {
query := r.URL.Query()
params := backendrpc.TextUnitQueryParams{
@@ -1299,15 +1411,52 @@ func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunPa
func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryConfirmParams) error {
if strings.TrimSpace(params.RecordID) == "" || strings.TrimSpace(params.Reviewer) == "" {
return errors.New("Translation Memory confirm requires record_id and reviewer")
return errors.New("translation memory confirm requires record_id and reviewer")
}
if strings.TrimSpace(params.SupersedeRecordID) != "" && strings.TrimSpace(params.Reason) == "" {
return errors.New("translation memory supersede requires reason")
}
return nil
}
func validateTranslationMemoryResolveConflictParams(params backendrpc.TranslationMemoryResolveConflictParams) error {
if strings.TrimSpace(params.WinnerRecordID) == "" ||
strings.TrimSpace(params.Reviewer) == "" ||
strings.TrimSpace(params.Reason) == "" {
return errors.New("translation memory conflict resolution requires winner_record_id, reviewer, and reason")
}
if len(params.ExpectedTrustedRecordIDs) == 0 {
return errors.New("translation memory conflict resolution requires expected_trusted_record_ids")
}
seen := make(map[string]struct{}, len(params.ExpectedTrustedRecordIDs))
for _, id := range params.ExpectedTrustedRecordIDs {
id = strings.TrimSpace(id)
if id == "" {
return errors.New("expected_trusted_record_ids cannot contain empty record IDs")
}
if _, ok := seen[id]; ok {
return errors.New("expected_trusted_record_ids must be unique")
}
seen[id] = struct{}{}
}
return nil
}
func validateLocalizedPublishParams(params backendrpc.LocalizedPublishParams) error {
hasFile := strings.TrimSpace(params.TranslationFile) != ""
if hasFile == params.FromWorker {
return errors.New("localized publish requires exactly one of translation_file or from_worker")
hasManifest := strings.TrimSpace(params.PatchManifest) != ""
inputs := 0
if hasFile {
inputs++
}
if params.FromWorker {
inputs++
}
if hasManifest {
inputs++
}
if inputs != 1 {
return errors.New("localized publish requires exactly one of translation_file, from_worker, or patch_manifest")
}
return nil
}
@@ -1368,10 +1517,17 @@ func (s *Server) writeControlBackendError(w http.ResponseWriter, action string,
message = "control request was canceled"
default:
var apiErr *backendrpc.APIError
if errors.As(err, &apiErr) && apiErr.Kind == "not_implemented" {
status = http.StatusNotImplemented
code = "control_not_implemented"
message = "Rust bat does not implement this control action"
if errors.As(err, &apiErr) {
switch apiErr.Kind {
case "not_implemented":
status = http.StatusNotImplemented
code = "control_not_implemented"
message = "Rust bat does not implement this control action"
case "rpc_invalid_params":
status = http.StatusBadRequest
code = "invalid_control_params"
message = apiErr.Message
}
}
}
s.logger.Printf("bat-api control action=%s error=%v", action, err)
+505 -46
View File
@@ -32,6 +32,95 @@ func fixtureRoot(t *testing.T) string {
return abs
}
func copyFixtureRoot(t *testing.T) string {
t.Helper()
source := fixtureRoot(t)
target := filepath.Join(t.TempDir(), "release")
if err := filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
destination := filepath.Join(target, rel)
if info.IsDir() {
return os.MkdirAll(destination, info.Mode().Perm())
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(destination, data, info.Mode().Perm())
}); err != nil {
t.Fatal(err)
}
return target
}
func fixtureRPCBackend(t *testing.T, root string) *fakeBackend {
t.Helper()
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
manifestEntries := make([]backendrpc.ResourceManifestEntry, 0, len(idx.Entries))
for _, entry := range idx.Entries {
size := entry.Bytes
manifestEntries = append(manifestEntries, backendrpc.ResourceManifestEntry{
URL: entry.URL,
Destination: entry.RelativePath,
Bytes: &size,
BLAKE3: entry.BLAKE3,
})
}
catalog, err := json.Marshal(map[string]any{
"available": true,
"status": "published",
"version": map[string]any{
"id": "official-fixture",
"resource_root": root,
},
})
if err != nil {
t.Fatal(err)
}
return &fakeBackend{
status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true},
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
catalog: catalog,
manifest: &backendrpc.ResourceManifestPage{
Available: true,
Channel: "official",
ReleaseID: "official-fixture",
ResourceRoot: root,
ManifestVersion: 1,
PublicationIdentity: "fixture-publication-v1",
MappingIdentity: "fixture-mapping-v1",
ManifestIdentity: "fixture-manifest-v1",
Generation: 1,
TotalEntries: len(manifestEntries),
Entries: manifestEntries,
},
releaseStatus: &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "valid",
}},
},
}
}
const fixtureCurrentCDNPath = "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
func TestLoadIndexFromResourceRoot(t *testing.T) {
idx, err := LoadIndexFromResourceRoot(fixtureRoot(t))
if err != nil {
@@ -89,6 +178,188 @@ func TestReleaseSummaryRequiresCompleteManifest(t *testing.T) {
}
}
func TestCurrentCDNRequiresRustWholeReleaseHealth(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T, root string)
}{
{
name: "missing entry",
mutate: func(t *testing.T, root string) {
t.Helper()
if err := os.Remove(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash")); err != nil {
t.Fatal(err)
}
},
},
{
name: "size mismatch",
mutate: func(t *testing.T, root string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("too-large"), 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "same-size corruption",
mutate: func(t *testing.T, root string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("CORRUPTED!"), 0o644); err != nil {
t.Fatal(err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := copyFixtureRoot(t)
tt.mutate(t, root)
backend := fixtureRPCBackend(t, root)
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "blocked",
StatusCode: "distribution.blocked",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
OfficialDistributionReady: false,
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "invalid",
Diagnostics: []string{"fixture integrity failure"},
}},
}
cfg := DefaultConfig()
cfg.RequireIndexed = false
cfg.RefreshInterval = 0
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
server := NewServer(cfg, backend, log.New(io.Discard, "", 0))
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
summary := server.index().Summary()
if summary.Ready || summary.Distribution.Ready ||
summary.Distribution.StatusCode != "distribution.blocked" {
t.Fatalf("summary=%+v", summary)
}
for _, path := range []string{"/readyz", "/v1/bootstrap"} {
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("%s status=%d body=%s", path, recorder.Code, recorder.Body.String())
}
}
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, fixtureCurrentCDNPath, nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("current CDN status=%d body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("ETag") != "" || recorder.Header().Get("Cache-Control") != "" {
t.Fatalf("unhealthy CDN headers=%v", recorder.Header())
}
})
}
}
func TestRefreshCurrentReleaseHealthTransitionsAndClearsFailure(t *testing.T) {
root := copyFixtureRoot(t)
backend := fixtureRPCBackend(t, root)
cfg := DefaultConfig()
cfg.RefreshInterval = 0
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
server := NewServer(cfg, backend, log.New(io.Discard, "", 0))
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
if !server.index().Summary().Ready {
t.Fatalf("initial release is not ready: %+v", server.index().Summary())
}
get := func() *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, fixtureCurrentCDNPath, nil))
return recorder
}
if recorder := get(); recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
t.Fatalf("healthy CDN status=%d body=%q", recorder.Code, recorder.Body.String())
}
// The bytes stay the same size, but Rust's next release.status result
// revokes whole-release distribution authorization.
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("CORRUPTED!"), 0o644); err != nil {
t.Fatal(err)
}
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "blocked",
StatusCode: "distribution.blocked",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "invalid",
}},
}
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
if server.index().Summary().Ready || get().Code != http.StatusServiceUnavailable {
t.Fatalf("unhealthy refresh summary=%+v", server.index().Summary())
}
backend.releaseStatusErr = errors.New("release.status transport failure")
if err := server.Refresh(context.Background()); err == nil {
t.Fatal("expected refresh failure")
}
if summary := server.index().Summary(); summary.Ready || summary.ResourceRoot != "" {
t.Fatalf("failed refresh retained snapshot=%+v", summary)
}
healthRecorder := httptest.NewRecorder()
server.Handler().ServeHTTP(healthRecorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
var health map[string]any
if err := json.Unmarshal(healthRecorder.Body.Bytes(), &health); err != nil {
t.Fatal(err)
}
refresh := health["refresh"].(map[string]any)
if refresh["last_error"] == "" {
t.Fatalf("refresh diagnostics=%v", refresh)
}
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("1234567890"), 0o644); err != nil {
t.Fatal(err)
}
backend.releaseStatusErr = nil
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
OfficialCurrentReleaseID: "official-fixture",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
Releases: []backendrpc.ReleaseSummary{{
Channel: "official",
ID: "official-fixture",
Current: true,
DistributionIntegrityStatus: "valid",
}},
}
if err := server.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
if summary := server.index().Summary(); !summary.Ready || !summary.Distribution.Ready {
t.Fatalf("recovered summary=%+v", summary)
}
if recorder := get(); recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
t.Fatalf("recovered CDN status=%d body=%q", recorder.Code, recorder.Body.String())
}
}
func TestSplitCDNPathRejectsEscape(t *testing.T) {
if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil {
t.Fatal("expected error")
@@ -553,18 +824,24 @@ func TestServerInfoRewritesAddressablesOnly(t *testing.T) {
}
type fakeBackend struct {
statusCalls int
doctorCalls int
status *backendrpc.DaemonStatusReport
doctor *backendrpc.DoctorReport
catalog json.RawMessage
resource *backendrpc.ResourceState
manifest *backendrpc.ResourceManifestPage
daemonLogs *backendrpc.LogsReport
taskList *backendrpc.TaskList
taskStatus *backendrpc.TaskRecord
taskLogs *backendrpc.TaskLogs
taskCancel *backendrpc.TaskCancelResult
statusCalls int
doctorCalls int
releaseStatusCalls int
attestationCalls int
status *backendrpc.DaemonStatusReport
doctor *backendrpc.DoctorReport
releaseStatus *backendrpc.ReleaseStatusReport
releaseStatusErr error
attestation *backendrpc.DistributionAttestation
catalog json.RawMessage
resource *backendrpc.ResourceState
manifest *backendrpc.ResourceManifestPage
manifestParams []backendrpc.ResourceManifestParams
daemonLogs *backendrpc.LogsReport
taskList *backendrpc.TaskList
taskStatus *backendrpc.TaskRecord
taskLogs *backendrpc.TaskLogs
taskCancel *backendrpc.TaskCancelResult
}
func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
@@ -573,11 +850,100 @@ func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatu
}
func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
f.doctorCalls++
if f.statusCalls == 0 {
// status must be called first in real DiscoverAndIndex; this is asserted by call order.
}
return f.doctor, nil
}
func (f *fakeBackend) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) {
f.releaseStatusCalls++
if f.releaseStatusErr != nil {
return nil, f.releaseStatusErr
}
if f.releaseStatus != nil {
return f.releaseStatus, nil
}
return &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
}, nil
}
func (f *fakeBackend) ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) {
f.attestationCalls++
// Keep the older test fixture controls useful while discovery moves to the
// lightweight RPC: releaseStatus still supplies the desired ready/blocked
// state unless a test explicitly installs an attestation.
if f.attestation != nil {
return f.attestation, nil
}
f.releaseStatusCalls++
if f.releaseStatusErr != nil {
return nil, f.releaseStatusErr
}
releaseStatus := f.releaseStatus
if releaseStatus == nil {
releaseStatus = &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
OfficialDistributionReady: true,
}
}
releaseID := "fixture-release"
resourceRoot := ""
if f.manifest != nil {
releaseID = f.manifest.ReleaseID
resourceRoot = f.manifest.ResourceRoot
}
var catalog struct {
Version struct {
ID string `json:"id"`
ResourceRoot string `json:"resource_root"`
} `json:"version"`
}
if json.Unmarshal(f.catalog, &catalog) == nil {
if catalog.Version.ID != "" {
releaseID = catalog.Version.ID
}
if catalog.Version.ResourceRoot != "" {
resourceRoot = catalog.Version.ResourceRoot
}
}
if releaseStatus.OfficialCurrentReleaseID != "" {
releaseID = releaseStatus.OfficialCurrentReleaseID
}
integrity := "verified"
for _, release := range releaseStatus.Releases {
if release.Channel == "official" && release.Current {
integrity = release.DistributionIntegrityStatus
break
}
}
if integrity == "valid" {
integrity = "verified"
}
verifiedAt := uint64(time.Now().Unix())
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,
VerifiedAt: &verifiedAt,
MaxAgeSeconds: 7260,
}, nil
}
func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
if f.resource != nil {
return f.resource, nil
@@ -587,8 +953,36 @@ func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceSt
func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return f.catalog, nil
}
func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
return f.manifest, nil
func (f *fakeBackend) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
if f.manifest == nil {
return nil, nil
}
f.manifestParams = append(f.manifestParams, params)
page := *f.manifest
if page.Channel == "" {
page.Channel = "official"
}
if page.ReleaseID == "" {
page.ReleaseID = params.ReleaseID
}
if page.ResourceRoot == "" {
page.ResourceRoot = f.manifest.ResourceRoot
}
if page.PublicationIdentity == "" {
page.PublicationIdentity = params.ExpectedPublicationIdentity
}
if page.ManifestIdentity == "" {
page.ManifestIdentity = params.ExpectedManifestIdentity
}
if page.MappingIdentity == "" {
page.MappingIdentity = "fixture-mapping-v1"
}
if page.Generation == 0 {
page.Generation = 1
}
page.Offset = params.Offset
page.Limit = params.Limit
return &page, nil
}
func (f *fakeBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
if f.daemonLogs != nil {
@@ -629,22 +1023,24 @@ func (f *fakeBackend) TaskCancel(ctx context.Context, taskID string) (*backendrp
type controlBackend struct {
*fakeBackend
calls []string
parseTextUnitQueries []backendrpc.TextUnitQueryParams
parseErrorQueries []backendrpc.TextUnitQueryParams
translationTaskUpdates []backendrpc.TranslationTaskUpdateParams
translationTaskListParams []backendrpc.TranslationTaskListParams
translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams
translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams
translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams
glossarySummaryParams []backendrpc.GlossarySummaryParams
glossaryQueryParams []backendrpc.GlossaryQueryParams
glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams
glossaryMutationParams []backendrpc.GlossaryTermMutationParams
glossaryReviewParams []backendrpc.GlossaryReviewParams
glossaryDeleteParams []backendrpc.GlossaryDeleteParams
localizedPublishParams []backendrpc.LocalizedPublishParams
localizedRollbackParams []backendrpc.LocalizedRollbackParams
calls []string
parseTextUnitQueries []backendrpc.TextUnitQueryParams
parseErrorQueries []backendrpc.TextUnitQueryParams
translationTaskUpdates []backendrpc.TranslationTaskUpdateParams
translationTaskListParams []backendrpc.TranslationTaskListParams
translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams
translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams
translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams
translationMemoryConflictsParams []backendrpc.TranslationMemoryConflictsParams
translationMemoryResolveParams []backendrpc.TranslationMemoryResolveConflictParams
glossarySummaryParams []backendrpc.GlossarySummaryParams
glossaryQueryParams []backendrpc.GlossaryQueryParams
glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams
glossaryMutationParams []backendrpc.GlossaryTermMutationParams
glossaryReviewParams []backendrpc.GlossaryReviewParams
glossaryDeleteParams []backendrpc.GlossaryDeleteParams
localizedPublishParams []backendrpc.LocalizedPublishParams
localizedRollbackParams []backendrpc.LocalizedRollbackParams
}
func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
@@ -789,18 +1185,19 @@ func (b *controlBackend) TranslationProofread(ctx context.Context) (json.RawMess
func (b *controlBackend) TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error) {
b.calls = append(b.calls, "translation.memory.summary")
b.translationMemorySummaryParams = append(b.translationMemorySummaryParams, params)
schemaVersion := uint64(1)
schemaVersion := uint64(2)
return &backendrpc.TranslationMemorySummaryReport{
Available: true,
Path: params.TranslationMemoryPath,
SchemaVersion: &schemaVersion,
Summary: &backendrpc.TranslationMemorySummary{
SchemaVersion: schemaVersion,
RecordCount: 2,
TrustedCount: 1,
CandidateCount: 1,
SupersededCount: 0,
RejectedCount: 0,
SchemaVersion: schemaVersion,
RecordCount: 2,
TrustedCount: 1,
CandidateCount: 1,
SupersededCount: 0,
RejectedCount: 0,
CurrentTrustedCount: 1,
},
}, nil
}
@@ -831,10 +1228,33 @@ func (b *controlBackend) TranslationMemoryConfirm(ctx context.Context, params ba
}, nil
}
func (b *controlBackend) TranslationMemoryConflicts(ctx context.Context, params backendrpc.TranslationMemoryConflictsParams) (*backendrpc.TranslationMemoryConflictsReport, error) {
b.calls = append(b.calls, "translation.memory.conflicts")
b.translationMemoryConflictsParams = append(b.translationMemoryConflictsParams, params)
return &backendrpc.TranslationMemoryConflictsReport{
Available: true,
Path: params.TranslationMemoryPath,
Conflicts: []backendrpc.TranslationMemoryConflict{},
}, nil
}
func (b *controlBackend) TranslationMemoryResolveConflict(ctx context.Context, params backendrpc.TranslationMemoryResolveConflictParams) (*backendrpc.TranslationMemoryResolveConflictReport, error) {
b.calls = append(b.calls, "translation.memory.resolve_conflict")
b.translationMemoryResolveParams = append(b.translationMemoryResolveParams, params)
return &backendrpc.TranslationMemoryResolveConflictReport{
Available: true,
Path: params.TranslationMemoryPath,
Entry: backendrpc.TranslationMemoryEntry{
RecordID: params.WinnerRecordID,
TrustStatus: backendrpc.TranslationMemoryStatusTrusted,
},
}, nil
}
func (b *controlBackend) GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) {
b.calls = append(b.calls, "translation.glossary.summary")
b.glossarySummaryParams = append(b.glossarySummaryParams, params)
schemaVersion := uint64(1)
schemaVersion := uint64(2)
return &backendrpc.GlossarySummaryReport{
Available: true,
Path: params.GlossaryPath,
@@ -1131,6 +1551,18 @@ func TestAdminTranslationQueryEndpointsProxyAuthenticatedRequests(t *testing.T)
t.Fatalf("missing TM query source status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/memory/conflicts?translation_memory_path=%2Fvar%2Flib%2Fbat%2Ftranslation-memory.sqlite&limit=7", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK ||
len(backend.translationMemoryConflictsParams) != 1 ||
backend.translationMemoryConflictsParams[0].TranslationMemoryPath != "/var/lib/bat/translation-memory.sqlite" ||
backend.translationMemoryConflictsParams[0].Limit == nil ||
*backend.translationMemoryConflictsParams[0].Limit != 7 {
t.Fatalf("TM conflicts status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.translationMemoryConflictsParams)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/summary?glossary_path=%2Fvar%2Flib%2Fbat%2Fglossary.sqlite", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
@@ -1243,8 +1675,8 @@ func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if fb.statusCalls != 1 || fb.doctorCalls != 1 {
t.Fatalf("status=%d doctor=%d", fb.statusCalls, fb.doctorCalls)
if fb.statusCalls != 1 || fb.doctorCalls != 1 || fb.releaseStatusCalls != 1 {
t.Fatalf("status=%d doctor=%d release_status=%d", fb.statusCalls, fb.doctorCalls, fb.releaseStatusCalls)
}
if !result.RPCAvailable || result.Index == nil || !result.Index.Summary().Ready {
t.Fatalf("result=%+v summary=%+v", result, result.Index.Summary())
@@ -1482,9 +1914,12 @@ func (p *pollingBackend) ResourceState(ctx context.Context) (*backendrpc.Resourc
func (p *pollingBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return nil, errors.New("unexpected catalog call")
}
func (p *pollingBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
func (p *pollingBackend) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
return nil, errors.New("unexpected manifest call")
}
func (p *pollingBackend) ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) {
return nil, errors.New("unexpected attestation call")
}
func TestStartRefreshLoopPollsBackend(t *testing.T) {
cfg := DefaultConfig()
@@ -1944,16 +2379,18 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"},
{name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"},
{name: "task cancel", action: "task-cancel", body: `{"task_id":"task-sync-1"}`, rpcMethod: "task.cancel", call: "task.cancel"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文","glossary_override":{"reviewer":"reviewer","reason":"approved deviation","provenance":"manual-review","confirmed_unix_seconds":100}}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文","glossary_override":{"qa_identity":"gqa-v1-test","reviewer":"reviewer","reason":"approved deviation","provenance":"manual-review","confirmed_unix_seconds":100}}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
{name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"},
{name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"},
{name: "translation memory confirm", action: "translation-memory-confirm", body: `{"record_id":"tm-record-1","reviewer":"reviewer","reason":"reviewed"}`, rpcMethod: "translation.memory.confirm", call: "translation.memory.confirm"},
{name: "translation memory resolve conflict", action: "translation-memory-resolve-conflict", body: `{"winner_record_id":"tm-record-1","expected_trusted_record_ids":["tm-record-1","tm-record-2"],"reviewer":"reviewer","reason":"selected"}`, rpcMethod: "translation.memory.resolve_conflict", call: "translation.memory.resolve_conflict"},
{name: "translation glossary add", action: "translation-glossary-add", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.add", call: "translation.glossary.add"},
{name: "translation glossary update", action: "translation-glossary-update", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","reviewer":"reviewer","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.update", call: "translation.glossary.update"},
{name: "translation glossary approve", action: "translation-glossary-approve", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"approved"}`, rpcMethod: "translation.glossary.approve", call: "translation.glossary.approve"},
{name: "translation glossary deprecate", action: "translation-glossary-deprecate", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"retired"}`, rpcMethod: "translation.glossary.deprecate", call: "translation.glossary.deprecate"},
{name: "translation glossary delete", action: "translation-glossary-delete", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"duplicate"}`, rpcMethod: "translation.glossary.delete", call: "translation.glossary.delete"},
{name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"},
{name: "localized patch manifest publish", action: "localized-publish", body: `{"patch_manifest":"/tmp/patch-manifest.json","localized_release_id":"localized-1"}`, rpcMethod: "localized.publish", call: "localized.publish"},
{name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"},
}
for _, tc := range tests {
@@ -1983,7 +2420,8 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
len(backend.translationTaskUpdates[0].TranslationResults) != 1 ||
backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" ||
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride == nil ||
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.Reviewer != "reviewer" {
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.Reviewer != "reviewer" ||
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.QAIdentity != "gqa-v1-test" {
t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates)
}
if len(backend.translationMemoryConfirmParams) != 1 ||
@@ -1991,6 +2429,11 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
backend.translationMemoryConfirmParams[0].Reviewer != "reviewer" {
t.Fatalf("TM confirm params=%#v", backend.translationMemoryConfirmParams)
}
if len(backend.translationMemoryResolveParams) != 1 ||
backend.translationMemoryResolveParams[0].WinnerRecordID != "tm-record-1" ||
len(backend.translationMemoryResolveParams[0].ExpectedTrustedRecordIDs) != 2 {
t.Fatalf("TM resolve params=%#v", backend.translationMemoryResolveParams)
}
if len(backend.glossaryMutationParams) != 2 ||
backend.glossaryMutationParams[0].TermID != "term-sensei" ||
backend.glossaryMutationParams[1].Reviewer != "reviewer" ||
@@ -2033,6 +2476,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
t.Fatalf("invalid TM confirm status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-memory-resolve-conflict", strings.NewReader(`{"winner_record_id":"tm-record-1","expected_trusted_record_ids":["tm-record-1","tm-record-1"],"reviewer":"reviewer","reason":"selected"}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid TM resolve status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"translation_file":"/tmp/workbench.json"}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
@@ -2040,6 +2491,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid localized publish status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"patch_manifest":"/tmp/patch-manifest.json"}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid localized patch manifest status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminLocalizedStatusRequiresAuthAndForwards(t *testing.T) {
+49 -16
View File
@@ -14,11 +14,6 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
idx := s.index()
if idx == nil || idx.ResourceRoot == "" {
http.Error(w, "resource root not ready", http.StatusServiceUnavailable)
return
}
_, rel, err := SplitCDNPath(r.URL.Path)
if err != nil {
http.NotFound(w, r)
@@ -27,15 +22,51 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
var entry ResourceEntry
var hasEntry bool
if s.cfg.RequireIndexed {
entry, hasEntry = idx.Lookup(rel)
if !hasEntry || !entry.Present || !entry.SizeMatch {
http.NotFound(w, r)
resourceRoot := ""
explicitRelease := false
channel := r.URL.Query().Get("channel")
releaseID := r.URL.Query().Get("release_id")
if channel != "" || releaseID != "" {
explicitRelease = true
channel, releaseID, selectorErr := releaseSelector(r)
if selectorErr != nil {
http.Error(w, selectorErr.Error(), http.StatusBadRequest)
return
}
selected, selectErr := s.loadReleaseDistribution(r, channel, releaseID, rel)
if selectErr != nil {
http.Error(w, selectErr.Error(), http.StatusServiceUnavailable)
return
}
if selected == nil || !selected.Available || selected.ResourceRoot == "" {
http.Error(w, "selected release is not distributable", http.StatusConflict)
return
}
resourceRoot = selected.ResourceRoot
entry, hasEntry = releaseDistributionEntry(selected, rel)
} else {
idx := s.index()
if idx == nil || idx.ResourceRoot == "" {
http.Error(w, "resource root not ready", http.StatusServiceUnavailable)
return
}
summary := idx.Summary()
if !summary.Ready {
// The cached health fact covers the whole current release. A
// locally present target is not enough to serve it as a healthy
// immutable artifact.
http.Error(w, "current release is not distributable", http.StatusServiceUnavailable)
return
}
resourceRoot = idx.ResourceRoot
entry, hasEntry = idx.Lookup(rel)
}
if !hasEntry || !entry.Present || !entry.SizeMatch {
http.NotFound(w, r)
return
}
abs, err := ResolveUnderRoot(idx.ResourceRoot, rel, true)
abs, err := ResolveUnderRoot(resourceRoot, rel, true)
if err != nil {
http.NotFound(w, r)
return
@@ -45,11 +76,13 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if s.cfg.RequireIndexed && s.cfg.VerifySize {
if hasEntry && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes {
http.Error(w, "size mismatch with release index", http.StatusConflict)
return
}
if explicitRelease && hasEntry && uint64(info.Size()) != entry.Bytes {
http.Error(w, "size mismatch with release index", http.StatusConflict)
return
}
if !explicitRelease && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes {
http.Error(w, "size mismatch with release index", http.StatusConflict)
return
}
file, err := os.Open(abs)
@@ -57,7 +90,7 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
defer file.Close()
defer func() { _ = file.Close() }()
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("ETag", cdnETag(entry, hasEntry, info))
+2 -1
View File
@@ -332,7 +332,8 @@ BAT_API_STATE_DIR=/tmp/bat-pid
# BAT_API_SOCKET=/tmp/bat-pid/bat.sock
# Optional override of the published release root (fixtures / emergency only).
# Production obtains resource_root from daemon RPC (catalog.status / resource.manifest).
# Production obtains resource_root from daemon RPC (release.attestation +
# generation-bound resource.manifest).
# BAT_API_RESOURCE_ROOT=
# Optional server-info JSON for Addressables root rewrite
+21 -1
View File
@@ -24,12 +24,14 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
availableRaw := readContractFixture(t, "catalog-status.available.json")
unavailableRaw := readContractFixture(t, "catalog-status.unavailable.json")
manifestRaw := readContractFixture(t, "resource-manifest.page0.json")
attestationRaw := readContractFixture(t, "release-attestation.json")
snapshotRaw := readContractFixture(t, "official-sync-snapshot.json")
for name, raw := range map[string][]byte{
"catalog available": availableRaw,
"catalog unavailable": unavailableRaw,
"resource manifest": manifestRaw,
"attestation": attestationRaw,
"snapshot": snapshotRaw,
} {
if bytes.Contains(raw, []byte("/tmp/")) {
@@ -71,7 +73,12 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
t.Fatalf("decode resource manifest: %v", err)
}
if !manifest.Available || manifest.ManifestVersion != 1 || manifest.TotalEntries != 2 {
if !manifest.Available || manifest.Channel != "official" ||
manifest.ManifestVersion != 1 || manifest.TotalEntries != 2 ||
manifest.ReleaseID != "${VERSION_ID}" ||
manifest.PublicationIdentity != "${PUBLICATION_IDENTITY}" ||
manifest.ManifestIdentity != "${MANIFEST_IDENTITY}" ||
manifest.Generation != 7 {
t.Fatalf("manifest header=%+v", manifest)
}
if len(manifest.Entries) != 2 {
@@ -84,6 +91,19 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
t.Fatalf("manifest first entry=%+v", manifest.Entries[0])
}
var attestation backendrpc.DistributionAttestation
if err := json.Unmarshal(attestationRaw, &attestation); err != nil {
t.Fatalf("decode attestation: %v", err)
}
if !attestation.Available || !attestation.Ready ||
attestation.ReleaseID != "${VERSION_ID}" ||
attestation.ManifestIdentity != "${MANIFEST_IDENTITY}" ||
attestation.VerificationGeneration != 7 ||
attestation.MaxAgeSeconds != 7260 ||
attestation.VerifiedAt == nil || *attestation.VerifiedAt != 1000 {
t.Fatalf("attestation=%+v", attestation)
}
var snapshot struct {
AppVersion string `json:"app_version"`
BundleVersion string `json:"bundle_version"`
+1
View File
@@ -111,6 +111,7 @@ func (s *Server) launcherBootstrapBody(sum ReleaseSummary) LauncherBootstrapResp
},
Resource: LauncherResource{
Release: sum.Snapshot,
Distribution: sum.Distribution,
ServerInfoURL: s.serverInfoURL(),
ClientPatchBaseURL: s.clientPatchBaseURL(),
},
+129 -10
View File
@@ -27,25 +27,25 @@ paths:
summary: Release readiness
responses:
"200":
description: A distributable release is available.
description: A current official release authorized by Rust release.attestation and fully represented by the bound local read snapshot is available.
"503":
description: No distributable release is available.
description: The Rust current attestation is unavailable, stale, invalid, or the bound local read snapshot is not distributable.
/v1/bootstrap:
get:
summary: Startup resource bootstrap
responses:
"200":
description: Resource bootstrap response.
description: Resource bootstrap response with the same distribution health used by readiness and current CDN serving.
"503":
description: Release is not ready.
description: The current release is not distributable.
/v1/launcher/bootstrap:
get:
summary: Launcher-shaped resource bootstrap
responses:
"200":
description: Launcher bootstrap response.
description: Launcher bootstrap response with the current release distribution health.
"503":
description: Release is not ready.
description: The current release is not distributable.
/api/launcher/game/config:
get:
summary: Resource-only launcher game config compatibility
@@ -78,7 +78,58 @@ paths:
summary: Current release summary
responses:
"200":
description: Release summary.
description: Release summary including Rust-owned whole-release distribution health.
/v1/releases:
get:
summary: Rust-owned official and localized release history
parameters:
- name: channel
in: query
schema:
type: string
enum: [official, localized]
responses:
"200":
description: Release history and manifest/artifact integrity summaries.
"503":
description: Rust bat release backend is unavailable.
/v1/distribution:
get:
summary: Select a verified official or localized release for distribution
parameters:
- name: channel
in: query
schema:
type: string
enum: [official, localized]
default: official
- name: release_id
in: query
schema:
type: string
- name: destination
in: query
description: Optional release-relative path for single-entry lookup; Rust returns exactly one entry and revalidates the selected channel's actual bytes and BLAKE3.
schema:
type: string
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
responses:
"200":
description: Rust-verified selected release and resource manifest page.
"409":
description: Selected release is missing, stale, damaged, or not distributable.
"503":
description: Rust bat release backend is unavailable.
/v1/resources:
get:
summary: Paginated resource manifest entries
@@ -461,6 +512,31 @@ paths:
description: Missing or invalid admin token.
"503":
description: Rust bat Translation Memory backend is unavailable.
/admin/translation/memory/conflicts:
get:
summary: List Rust-owned Translation Memory Trusted conflicts
parameters:
- name: limit
in: query
schema:
type: integer
format: int64
minimum: 1
maximum: 1000
default: 100
- name: translation_memory_path
in: query
schema:
type: string
responses:
"200":
description: Translation Memory exact-identity Trusted conflict groups.
"400":
description: Invalid conflict list limit.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Translation Memory backend is unavailable.
/admin/translation/glossary/summary:
get:
summary: Read Rust-owned Glossary summary
@@ -534,7 +610,7 @@ paths:
type: string
responses:
"200":
description: Glossary constraints, diagnostics, and blocked decision.
description: Glossary constraints, diagnostics, blocked decision, and stable qa_identity.
"400":
description: Missing source text or invalid context.
"401":
@@ -551,6 +627,32 @@ paths:
description: Missing or invalid admin token.
"503":
description: Rust bat localized backend is unavailable.
/admin/releases/status:
get:
summary: Read the unified Rust-owned release status view
responses:
"200":
description: Official/localized current relation and integrity status.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat release backend is unavailable.
/admin/releases:
get:
summary: Read Rust-owned historical release summaries
parameters:
- name: channel
in: query
schema:
type: string
enum: [official, localized]
responses:
"200":
description: Historical release summaries.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat release backend is unavailable.
/admin/control/{action}:
post:
summary: Forward an allowlisted control or schedule action to Rust bat
@@ -560,7 +662,7 @@ paths:
required: true
schema:
type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback]
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-memory-resolve-conflict, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback, release-cleanup]
requestBody:
required: false
content:
@@ -628,9 +730,12 @@ paths:
type: string
glossary_override:
type: object
required: [reviewer, reason, provenance, confirmed_unix_seconds]
required: [qa_identity, reviewer, reason, provenance, confirmed_unix_seconds]
additionalProperties: false
properties:
qa_identity:
type: string
minLength: 1
reviewer:
type: string
reason:
@@ -672,6 +777,18 @@ paths:
type: string
record_id:
type: string
winner_record_id:
type: string
expected_trusted_record_ids:
type: array
items:
type: string
supersede_record_id:
type: string
reviewer:
type: string
reason:
type: string
term_id:
type: string
source_term:
@@ -727,6 +844,8 @@ paths:
type: string
from_worker:
type: boolean
patch_manifest:
type: string
localized_release_id:
type: string
responses:
+285
View File
@@ -0,0 +1,285 @@
package api
import (
"context"
"encoding/json"
"testing"
"time"
"bat-api/internal/backendrpc"
)
func testAttestation(root, releaseID, integrity string, ready bool) *backendrpc.DistributionAttestation {
verifiedAt := uint64(time.Now().Unix())
return &backendrpc.DistributionAttestation{
Available: true,
Channel: "official",
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,
VerifiedAt: &verifiedAt,
MaxAgeSeconds: 7260,
}
}
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: "generation",
mutate: func(page *backendrpc.ResourceManifestPage) { page.Generation = 5 },
},
{
name: "publication identity",
mutate: func(page *backendrpc.ResourceManifestPage) { page.PublicationIdentity = "publication-b" },
},
{
name: "mapping identity",
mutate: func(page *backendrpc.ResourceManifestPage) { page.MappingIdentity = "mapping-b" },
},
{
name: "manifest version",
mutate: func(page *backendrpc.ResourceManifestPage) { page.ManifestVersion = 2 },
},
{
name: "total",
mutate: func(page *backendrpc.ResourceManifestPage) { page.TotalEntries = 4 },
},
{
name: "limit",
mutate: func(page *backendrpc.ResourceManifestPage) { page.Limit = 1 },
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
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 ||
backend.params[1].ExpectedVerificationGeneration != attestation.VerificationGeneration {
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())
}
})
}
}
func TestExpiredReadyAttestationNeverReadiesIndex(t *testing.T) {
root := fixtureRoot(t)
backend := fixtureRPCBackend(t, root)
attestation := testAttestation(root, "official-fixture", "verified", true)
expired := uint64(time.Now().Unix()) - attestation.MaxAgeSeconds - 1
attestation.VerifiedAt = &expired
backend.attestation = attestation
result, err := DiscoverAndIndex(context.Background(), backend, "")
if err != nil {
t.Fatal(err)
}
if result.Index == nil || result.Index.Summary().Ready {
t.Fatalf("expired attestation unexpectedly ready: %+v", result.Index.Summary())
}
if len(backend.manifestParams) != 0 {
t.Fatalf("manifest should not be fetched for expired attestation: %+v", backend.manifestParams)
}
}
+253
View File
@@ -0,0 +1,253 @@
package api
import (
"net/http"
"strconv"
"strings"
"bat-api/internal/backendrpc"
)
func (s *Server) handleReleaseList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
return
}
channel := r.URL.Query().Get("channel")
result, err := backend.ReleaseList(r.Context(), backendrpc.ReleaseListParams{Channel: channel})
if err != nil {
s.writeControlBackendError(w, "release-list", err)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleReleaseDistribution(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
channel, releaseID, err := releaseSelector(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_release_selector", err.Error())
return
}
params, err := releaseDistributionParams(r, channel, releaseID)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_release_query", err.Error())
return
}
page, err := s.requestReleaseDistribution(r, params)
if err != nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", err.Error())
return
}
if page == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust release distribution returned no result")
return
}
status := http.StatusOK
if !page.Available {
status = http.StatusConflict
}
writeNoStoreJSON(w, status, page)
}
func (s *Server) handleAdminReleaseStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
return
}
result, err := backend.ReleaseStatus(r.Context())
if err != nil {
s.writeControlBackendError(w, "release-status", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminReleaseList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
return
}
result, err := backend.ReleaseList(r.Context(), backendrpc.ReleaseListParams{
Channel: r.URL.Query().Get("channel"),
})
if err != nil {
s.writeControlBackendError(w, "release-list", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func releaseSelector(r *http.Request) (string, string, error) {
channel := strings.TrimSpace(r.URL.Query().Get("channel"))
releaseID := strings.TrimSpace(r.URL.Query().Get("release_id"))
if channel == "" {
channel = "official"
}
if channel != "official" && channel != "localized" {
return "", "", &releaseSelectorError{message: "channel must be official or localized"}
}
if releaseID == "." || releaseID == ".." ||
strings.Contains(releaseID, "/") ||
strings.Contains(releaseID, "\\") ||
strings.Contains(releaseID, ":") ||
strings.ContainsRune(releaseID, 0) {
return "", "", &releaseSelectorError{message: "release_id contains an unsafe path character"}
}
return channel, releaseID, nil
}
type releaseSelectorError struct {
message string
}
func (e *releaseSelectorError) Error() string {
return e.message
}
func (s *Server) loadReleaseDistribution(r *http.Request, channel, releaseID, destination string) (*backendrpc.ReleaseDistributionPage, error) {
return s.loadReleaseDistributionFrom(r, backendrpc.ReleaseDistributionParams{
Channel: channel,
ReleaseID: releaseID,
Destination: destination,
Offset: 0,
Limit: 1000,
})
}
func releaseDistributionParams(r *http.Request, channel, releaseID string) (backendrpc.ReleaseDistributionParams, error) {
params := backendrpc.ReleaseDistributionParams{
Channel: channel,
ReleaseID: releaseID,
}
query := r.URL.Query()
if destination := strings.TrimSpace(query.Get("destination")); destination != "" {
params.Destination = destination
}
if raw := strings.TrimSpace(query.Get("offset")); raw != "" {
offset, err := strconv.ParseUint(raw, 10, 64)
if err != nil || uint64(int(^uint(0)>>1)) < offset {
return backendrpc.ReleaseDistributionParams{}, &releaseSelectorError{
message: "offset must be a non-negative integer",
}
}
params.Offset = int(offset)
}
if raw := strings.TrimSpace(query.Get("limit")); raw != "" {
limit, err := strconv.ParseUint(raw, 10, 64)
if err != nil || limit == 0 || limit > 1000 {
return backendrpc.ReleaseDistributionParams{}, &releaseSelectorError{
message: "limit must be in 1..=1000",
}
}
params.Limit = int(limit)
}
return params, nil
}
func (s *Server) requestReleaseDistribution(r *http.Request, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
return nil, &releaseSelectorError{message: "Rust bat release backend is unavailable"}
}
return backend.ReleaseDistribution(r.Context(), params)
}
func (s *Server) loadReleaseDistributionFrom(r *http.Request, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
return nil, &releaseSelectorError{message: "Rust bat release backend is unavailable"}
}
pageSize := 1000
result, err := backend.ReleaseDistribution(r.Context(), params)
if err != nil || result == nil || !result.Available {
return result, err
}
if params.Destination != "" {
if result.Total != 1 || result.Offset != 0 || result.Limit != 1 || len(result.Entries) != 1 {
return nil, &releaseSelectorError{message: "Rust single-entry release distribution response is invalid"}
}
return result, nil
}
if result.Total <= len(result.Entries) {
return result, nil
}
all := append([]backendrpc.ReleaseDistributionEntry(nil), result.Entries...)
for offset := len(all); offset < result.Total; {
next, nextErr := backend.ReleaseDistribution(r.Context(), backendrpc.ReleaseDistributionParams{
Channel: params.Channel,
ReleaseID: params.ReleaseID,
Destination: params.Destination,
Offset: offset,
Limit: pageSize,
})
if nextErr != nil {
return nil, nextErr
}
if next == nil || !next.Available || len(next.Entries) == 0 {
return nil, &releaseSelectorError{message: "Rust release distribution page is incomplete"}
}
all = append(all, next.Entries...)
offset = len(all)
if len(all) > result.Total {
all = all[:result.Total]
break
}
}
result.Entries = all
result.Offset = 0
result.Limit = len(all)
return result, nil
}
func releaseDistributionEntry(page *backendrpc.ReleaseDistributionPage, rel string) (ResourceEntry, bool) {
rel = strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/")
for _, entry := range page.Entries {
destination := strings.TrimPrefix(strings.ReplaceAll(entry.Destination, "\\", "/"), "/")
if destination == rel {
return ResourceEntry{
URL: entry.URL,
RelativePath: destination,
Bytes: entry.Bytes,
BLAKE3: entry.BLAKE3,
Present: true,
SizeMatch: true,
}, true
}
}
return ResourceEntry{}, false
}
+376
View File
@@ -0,0 +1,376 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"bat-api/internal/backendrpc"
)
type releaseBackendStub struct {
*fakeBackend
root string
available bool
distributionParams []backendrpc.ReleaseDistributionParams
largeDistribution bool
}
type variableDistributionBackend struct {
*releaseBackendStub
officialRoot string
localizedRoot string
officialBytes []byte
localizedBytes []byte
}
func (b *variableDistributionBackend) ReleaseDistribution(_ context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
b.distributionParams = append(b.distributionParams, params)
root := b.officialRoot
data := b.officialBytes
channel := "official"
releaseID := "official-1"
hash := "official-b3"
if params.Channel == "localized" {
root = b.localizedRoot
data = b.localizedBytes
channel = "localized"
releaseID = "localized-1"
hash = "localized-b3"
}
return &backendrpc.ReleaseDistributionPage{
Available: true,
Channel: channel,
ReleaseID: releaseID,
ResourceRoot: root,
Status: "ready",
StatusCode: "distribution.ready",
ArtifactIntegrityStatus: "valid",
Total: 1,
Offset: 0,
Limit: 1,
Entries: []backendrpc.ReleaseDistributionEntry{{
URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Destination: "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Bytes: uint64(len(data)),
BLAKE3: hash,
}},
}, nil
}
func (b *controlBackend) ReleaseStatus(context.Context) (*backendrpc.ReleaseStatusReport, error) {
b.calls = append(b.calls, "release.status")
return &backendrpc.ReleaseStatusReport{Status: "ready", StatusCode: "distribution.ready"}, nil
}
func (b *controlBackend) ReleaseList(context.Context, backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) {
b.calls = append(b.calls, "release.list")
return &backendrpc.ReleaseListReport{Status: "ready", StatusCode: "distribution.ready"}, nil
}
func (b *controlBackend) ReleaseDistribution(context.Context, backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
b.calls = append(b.calls, "release.distribution")
return &backendrpc.ReleaseDistributionPage{Available: false, StatusCode: "distribution.blocked"}, nil
}
func (b *controlBackend) ReleaseCleanup(context.Context, backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) {
b.calls = append(b.calls, "release.cleanup")
return &backendrpc.ReleaseCleanupReport{PlanID: "plan-1"}, nil
}
func (b *releaseBackendStub) ReleaseStatus(context.Context) (*backendrpc.ReleaseStatusReport, error) {
return &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
OfficialCurrentReleaseID: "official-1",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
}, nil
}
func (b *releaseBackendStub) ReleaseList(context.Context, backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) {
return &backendrpc.ReleaseListReport{
Status: "ready",
StatusCode: "distribution.ready",
Releases: []backendrpc.ReleaseSummary{{
Channel: "localized",
ID: "localized-1",
ManifestContractStatus: "valid",
ArtifactIntegrityStatus: "valid",
DistributionIntegrityStatus: "valid",
}},
}, nil
}
func (b *releaseBackendStub) ReleaseDistribution(_ context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
b.distributionParams = append(b.distributionParams, params)
if b.largeDistribution {
target := "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
if params.Destination != "" {
return &backendrpc.ReleaseDistributionPage{
Available: b.available,
Channel: params.Channel,
ReleaseID: params.ReleaseID,
ResourceRoot: b.root,
Status: "ready",
StatusCode: "distribution.ready",
ArtifactIntegrityStatus: "valid",
Total: 1,
Offset: 0,
Limit: 1,
Entries: []backendrpc.ReleaseDistributionEntry{{
URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Destination: target,
Bytes: 21,
BLAKE3: "not-used-by-http-index",
}},
}, nil
}
entries := make([]backendrpc.ReleaseDistributionEntry, 5000)
for index := range entries {
entries[index] = backendrpc.ReleaseDistributionEntry{
URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/resource.bytes",
Destination: "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/resource-" + strconv.Itoa(index) + ".bytes",
Bytes: 21,
BLAKE3: "not-used-by-http-index",
}
}
entries[0].Destination = target
return &backendrpc.ReleaseDistributionPage{
Available: b.available,
Channel: params.Channel,
ReleaseID: params.ReleaseID,
ResourceRoot: b.root,
Status: "ready",
StatusCode: "distribution.ready",
ArtifactIntegrityStatus: "valid",
Total: len(entries),
Offset: 0,
Limit: len(entries),
Entries: entries,
}, nil
}
return &backendrpc.ReleaseDistributionPage{
Available: b.available,
Channel: params.Channel,
ReleaseID: params.ReleaseID,
ResourceRoot: b.root,
Status: "ready",
StatusCode: "distribution.ready",
ArtifactIntegrityStatus: "valid",
Total: 1,
Offset: 0,
Limit: 1,
Entries: []backendrpc.ReleaseDistributionEntry{{
URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Destination: "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Bytes: 21,
BLAKE3: "not-used-by-http-index",
}},
}, nil
}
func TestCDNSingleEntryLookupDoesNotPaginateLargeDistribution(t *testing.T) {
cfg := DefaultConfig()
cfg.RequireIndexed = false
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &releaseBackendStub{
fakeBackend: &fakeBackend{},
root: fixtureRoot(t),
available: true,
largeDistribution: true,
}
server := NewServer(cfg, backend, nil)
request := httptest.NewRequest(
http.MethodGet,
"/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=localized&release_id=localized-1",
nil,
)
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
t.Fatalf("large distribution CDN status=%d body=%q", recorder.Code, recorder.Body.String())
}
if len(backend.distributionParams) != 1 {
t.Fatalf("single-entry lookup made %d backend calls", len(backend.distributionParams))
}
params := backend.distributionParams[0]
if params.Destination != "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes" {
t.Fatalf("single-entry destination=%q", params.Destination)
}
}
func TestCDNUsesLocalizedBytesAndHashForGetAndHead(t *testing.T) {
cfg := DefaultConfig()
cfg.RequireIndexed = false
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
rel := filepath.FromSlash("prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes")
officialBytes := []byte("official-A")
localizedBytes := []byte("localized-B-with-a-different-length")
officialRoot := t.TempDir()
localizedRoot := t.TempDir()
if err := os.MkdirAll(filepath.Dir(filepath.Join(officialRoot, rel)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(filepath.Join(localizedRoot, rel)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(officialRoot, rel), officialBytes, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(localizedRoot, rel), localizedBytes, 0o644); err != nil {
t.Fatal(err)
}
backend := &variableDistributionBackend{
releaseBackendStub: &releaseBackendStub{fakeBackend: &fakeBackend{}},
officialRoot: officialRoot,
localizedRoot: localizedRoot,
officialBytes: officialBytes,
localizedBytes: localizedBytes,
}
server := NewServer(cfg, backend, nil)
localizedURL := "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=localized&release_id=localized-1"
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, localizedURL, nil))
if recorder.Code != http.StatusOK || recorder.Body.String() != string(localizedBytes) {
t.Fatalf("localized GET status=%d body=%q", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("ETag") != `"blake3-localized-b3"` {
t.Fatalf("localized ETag=%q", recorder.Header().Get("ETag"))
}
if recorder.Result().ContentLength != int64(len(localizedBytes)) {
t.Fatalf("localized Content-Length=%d", recorder.Result().ContentLength)
}
recorder = httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodHead, localizedURL, nil))
if recorder.Code != http.StatusOK || recorder.Body.Len() != 0 {
t.Fatalf("localized HEAD status=%d body=%d", recorder.Code, recorder.Body.Len())
}
if recorder.Header().Get("ETag") != `"blake3-localized-b3"` ||
recorder.Result().ContentLength != int64(len(localizedBytes)) {
t.Fatalf("localized HEAD headers etag=%q length=%d", recorder.Header().Get("ETag"), recorder.Result().ContentLength)
}
officialURL := "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=official&release_id=official-1"
recorder = httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, officialURL, nil))
if recorder.Code != http.StatusOK || recorder.Body.String() != string(officialBytes) {
t.Fatalf("official GET status=%d body=%q", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("ETag") != `"blake3-official-b3"` ||
recorder.Result().ContentLength != int64(len(officialBytes)) {
t.Fatalf("official headers etag=%q length=%d", recorder.Header().Get("ETag"), recorder.Result().ContentLength)
}
if len(backend.distributionParams) != 3 {
t.Fatalf("backend calls=%d want=3", len(backend.distributionParams))
}
for _, params := range backend.distributionParams {
if params.Destination != "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes" {
t.Fatalf("backend destination=%q", params.Destination)
}
}
}
func (*releaseBackendStub) ReleaseCleanup(context.Context, backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) {
return &backendrpc.ReleaseCleanupReport{PlanID: "plan-1"}, nil
}
func TestReleaseHTTPForwardsTypedSelectionAndDoesNotFallback(t *testing.T) {
cfg := DefaultConfig()
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &releaseBackendStub{fakeBackend: &fakeBackend{}, root: fixtureRoot(t), available: true}
server := NewServer(cfg, backend, nil)
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/releases?channel=localized", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("release list status=%d body=%s", recorder.Code, recorder.Body.String())
}
recorder = httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/distribution?channel=localized&release_id=localized-1&destination=TableBundles%2FTableCatalog.bytes", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("distribution status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.distributionParams) != 1 ||
backend.distributionParams[0].Channel != "localized" ||
backend.distributionParams[0].ReleaseID != "localized-1" ||
backend.distributionParams[0].Destination != "TableBundles/TableCatalog.bytes" ||
backend.distributionParams[0].Offset != 0 ||
backend.distributionParams[0].Limit != 0 {
t.Fatalf("distribution params=%#v", backend.distributionParams)
}
recorder = httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=localized&release_id=localized-1", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("localized CDN status=%d body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
t.Fatalf("localized CDN body=%q", recorder.Body.String())
}
cfg.RequireIndexed = false
unindexedServer := NewServer(cfg, backend, nil)
recorder = httptest.NewRecorder()
unindexedServer.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/not-listed.bytes?channel=localized&release_id=localized-1", nil))
if recorder.Code != http.StatusNotFound {
t.Fatalf("unlisted localized CDN status=%d body=%s", recorder.Code, recorder.Body.String())
}
blocked := &releaseBackendStub{fakeBackend: &fakeBackend{}, root: fixtureRoot(t), available: false}
blockedServer := NewServer(cfg, blocked, nil)
recorder = httptest.NewRecorder()
blockedServer.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=localized", nil))
if recorder.Code != http.StatusConflict {
t.Fatalf("blocked localized CDN status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminReleaseCleanupRequiresAuthAndForwards(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &controlBackend{fakeBackend: &fakeBackend{}}
server := NewServer(cfg, backend, nil)
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/admin/control/release-cleanup", nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated cleanup status=%d", recorder.Code)
}
request := httptest.NewRequest(http.MethodPost, "/admin/control/release-cleanup", strings.NewReader(`{"execute":false}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted {
t.Fatalf("dry-run cleanup status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.calls) != 1 || backend.calls[0] != "release.cleanup" {
t.Fatalf("calls=%v", backend.calls)
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/release-cleanup", strings.NewReader(`{"execute":true}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("missing plan cleanup status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
+84 -23
View File
@@ -55,17 +55,43 @@ type GameMainConfigSummary struct {
DefaultConnectionGroup string `json:"default_connection_group,omitempty"`
}
// DistributionHealth is the release-level authorization used by read paths.
//
// In RPC mode these fields are copied from Rust's current official
// attestation. The local manifest checks only establish that this process has
// a complete, safe read snapshot; they do not replace Rust's verifier.
type DistributionHealth struct {
Available bool `json:"available"`
Ready bool `json:"ready"`
Source string `json:"source"`
Channel string `json:"channel,omitempty"`
ReleaseID string `json:"release_id,omitempty"`
ResourceRoot string `json:"resource_root,omitempty"`
PublicationIdentity string `json:"publication_identity,omitempty"`
MappingIdentity string `json:"mapping_identity,omitempty"`
ManifestIdentity string `json:"manifest_identity,omitempty"`
EntryCount int `json:"entry_count,omitempty"`
VerificationGeneration uint64 `json:"verification_generation,omitempty"`
VerifiedAt *uint64 `json:"verified_at,omitempty"`
MaxAgeSeconds uint64 `json:"max_age_seconds,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.
type ReleaseIndex struct {
mu sync.RWMutex
ResourceRoot string `json:"resource_root"`
Source string `json:"source"` // "rpc" | "resource_root" | "empty"
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
Entries []ResourceEntry `json:"entries"`
ResourceRoot string `json:"resource_root"`
Source string `json:"source"` // "rpc" | "resource_root" | "empty"
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Distribution DistributionHealth `json:"distribution"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
Entries []ResourceEntry `json:"entries"`
// byRel maps relative path (host/path...) to entry index.
byRel map[string]int
// MissingOnDisk lists relative paths present in the index but absent on disk.
@@ -74,16 +100,17 @@ type ReleaseIndex struct {
// Summary returns a JSON-serializable overview without the full entry list.
type ReleaseSummary struct {
ResourceRoot string `json:"resource_root"`
Source string `json:"source"`
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
EntryCount int `json:"entry_count"`
PresentCount int `json:"present_count"`
MissingCount int `json:"missing_count"`
Ready bool `json:"ready"`
ResourceRoot string `json:"resource_root"`
Source string `json:"source"`
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Distribution DistributionHealth `json:"distribution"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
EntryCount int `json:"entry_count"`
PresentCount int `json:"present_count"`
MissingCount int `json:"missing_count"`
Ready bool `json:"ready"`
}
// Summary builds a compact release overview.
@@ -96,20 +123,37 @@ func (idx *ReleaseIndex) Summary() ReleaseSummary {
present++
}
}
distribution := idx.Distribution
distribution.Diagnostics = append([]string(nil), idx.Distribution.Diagnostics...)
localComplete := idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries)
// Hand-built indexes are retained for compatibility with local tests and
// diagnostics. Any index explicitly sourced from RPC must carry the Rust
// health fact; an RPC index without it is never considered distributable.
if distribution.Source == "" {
distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest"
} else {
distribution.Ready = distribution.Available &&
distribution.Ready &&
localComplete &&
(idx.Source != "rpc" ||
(distribution.ManifestIdentity != "" &&
distribution.EntryCount == len(idx.Entries)))
}
return ReleaseSummary{
ResourceRoot: idx.ResourceRoot,
Source: idx.Source,
RPCAvailable: idx.RPCAvailable,
DoctorHealthy: idx.DoctorHealthy,
Distribution: distribution,
Snapshot: idx.Snapshot,
ManifestVersion: idx.ManifestVersion,
EntryCount: len(idx.Entries),
PresentCount: present,
MissingCount: len(idx.MissingOnDisk),
// A release is distributable only when every manifest entry is present
// and has the expected size. Serving a partial release can leave clients
// with an apparently valid bootstrap and an unrecoverable download set.
Ready: idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries),
// A release is distributable only when Rust authorizes it and every
// entry in this process's read snapshot is usable. Serving a partial
// release can leave clients with an unrecoverable download set.
Ready: distribution.Ready,
}
}
@@ -158,6 +202,7 @@ func BuildIndexFromManifestEntries(
snapshot *SnapshotSummary,
manifestVersion int,
entries []manifestEntry,
distribution DistributionHealth,
) (*ReleaseIndex, error) {
rootAbs, err := filepath.Abs(resourceRoot)
if err != nil {
@@ -168,7 +213,8 @@ func BuildIndexFromManifestEntries(
Source: source,
RPCAvailable: rpcAvailable,
DoctorHealthy: doctorHealthy,
Snapshot: snapshot,
Distribution: distribution,
Snapshot: snapshotWithDistributionHealth(snapshot, distribution),
ManifestVersion: manifestVersion,
byRel: make(map[string]int),
}
@@ -258,7 +304,22 @@ func LoadIndexFromResourceRoot(resourceRoot string) (*ReleaseIndex, error) {
}
}
}
return BuildIndexFromManifestEntries(rootAbs, "resource_root", false, nil, snapshot, manifest.Version, entries)
return BuildIndexFromManifestEntries(
rootAbs,
"resource_root",
false,
nil,
snapshot,
manifest.Version,
entries,
DistributionHealth{
Available: true,
Ready: true,
Source: "resource_root_override",
Status: "ready",
StatusCode: "resource_root.ready",
},
)
}
type manifestEntry struct {
+15 -13
View File
@@ -51,16 +51,17 @@ type BootstrapAPI struct {
}
type BootstrapResource struct {
Release *SnapshotSummary `json:"release,omitempty"`
ResourceRoot string `json:"resource_root"`
Source string `json:"source"`
ManifestVersion int `json:"manifest_version,omitempty"`
EntryCount int `json:"entry_count"`
PresentCount int `json:"present_count"`
MissingCount int `json:"missing_count"`
ServerInfoURL string `json:"server_info_url"`
ClientPatchBaseURL string `json:"client_patch_base_url"`
AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"`
Release *SnapshotSummary `json:"release,omitempty"`
ResourceRoot string `json:"resource_root"`
Source string `json:"source"`
Distribution DistributionHealth `json:"distribution"`
ManifestVersion int `json:"manifest_version,omitempty"`
EntryCount int `json:"entry_count"`
PresentCount int `json:"present_count"`
MissingCount int `json:"missing_count"`
ServerInfoURL string `json:"server_info_url"`
ClientPatchBaseURL string `json:"client_patch_base_url"`
AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"`
}
type BootstrapPolicy struct {
@@ -113,9 +114,10 @@ type LauncherPolicy struct {
}
type LauncherResource struct {
Release *SnapshotSummary `json:"release,omitempty"`
ServerInfoURL string `json:"server_info_url"`
ClientPatchBaseURL string `json:"client_patch_base_url"`
Release *SnapshotSummary `json:"release,omitempty"`
Distribution DistributionHealth `json:"distribution"`
ServerInfoURL string `json:"server_info_url"`
ClientPatchBaseURL string `json:"client_patch_base_url"`
}
type LauncherEndpointSet struct {
+353 -42
View File
@@ -6,22 +6,25 @@ import (
"fmt"
"os"
"path/filepath"
"time"
"bat-api/internal/backendrpc"
)
// Backend is the subset of daemon RPC used by bat-api.
//
// Call order for discovery (per plan review):
// Call order for discovery:
// 1. daemon.status
// 2. daemon.doctor
// 3. catalog.status / resource.manifest (and resource.state as needed)
// 3. release.attestation
// 4. catalog.status / bound resource.manifest (and resource.state as needed)
type Backend interface {
DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error)
DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error)
ResourceState(ctx context.Context) (*backendrpc.ResourceState, error)
CatalogStatus(ctx context.Context) (json.RawMessage, error)
ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error)
ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error)
ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error)
}
// ControlBackend is the explicitly allowlisted mutation subset exposed through
@@ -90,6 +93,8 @@ type TranslationMemoryBackend interface {
TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error)
TranslationMemoryQuery(ctx context.Context, params backendrpc.TranslationMemoryQueryParams) (*backendrpc.TranslationMemoryQueryReport, error)
TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error)
TranslationMemoryConflicts(ctx context.Context, params backendrpc.TranslationMemoryConflictsParams) (*backendrpc.TranslationMemoryConflictsReport, error)
TranslationMemoryResolveConflict(ctx context.Context, params backendrpc.TranslationMemoryResolveConflictParams) (*backendrpc.TranslationMemoryResolveConflictReport, error)
}
// GlossaryBackend exposes Rust-owned Glossary management and diagnostics.
@@ -113,6 +118,22 @@ type LocalizedBackend interface {
LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error)
}
// ReleaseStatusBackend exposes the Rust-owned release health fact used during
// discovery. It is kept separate so lightweight test/diagnostic backends do
// not have to implement the administrative release surface.
type ReleaseStatusBackend interface {
ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error)
}
// ReleaseBackend exposes Rust-owned dual-release queries, distribution
// selection and the explicit cleanup operation.
type ReleaseBackend interface {
ReleaseStatusBackend
ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error)
ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error)
ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error)
}
// RPCClient adapts *backendrpc.Client to Backend.
type RPCClient struct {
Client *backendrpc.Client
@@ -130,8 +151,11 @@ func (r RPCClient) ResourceState(ctx context.Context) (*backendrpc.ResourceState
func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.CatalogStatus(ctx)
}
func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
return r.Client.ResourceManifest(ctx, offset, limit)
func (r RPCClient) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
return r.Client.ResourceManifest(ctx, params)
}
func (r RPCClient) ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) {
return r.Client.ReleaseAttestation(ctx)
}
func (r RPCClient) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) {
return r.Client.DaemonRestart(ctx)
@@ -216,6 +240,14 @@ func (r RPCClient) TranslationMemoryConfirm(ctx context.Context, params backendr
return r.Client.TranslationMemoryConfirm(ctx, params)
}
func (r RPCClient) TranslationMemoryConflicts(ctx context.Context, params backendrpc.TranslationMemoryConflictsParams) (*backendrpc.TranslationMemoryConflictsReport, error) {
return r.Client.TranslationMemoryConflicts(ctx, params)
}
func (r RPCClient) TranslationMemoryResolveConflict(ctx context.Context, params backendrpc.TranslationMemoryResolveConflictParams) (*backendrpc.TranslationMemoryResolveConflictReport, error) {
return r.Client.TranslationMemoryResolveConflict(ctx, params)
}
func (r RPCClient) GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) {
return r.Client.GlossarySummary(ctx, params)
}
@@ -253,6 +285,22 @@ func (r RPCClient) LocalizedRollback(ctx context.Context, params backendrpc.Loca
return r.Client.LocalizedRollback(ctx, params)
}
func (r RPCClient) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) {
return r.Client.ReleaseStatus(ctx)
}
func (r RPCClient) ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) {
return r.Client.ReleaseList(ctx, params)
}
func (r RPCClient) ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
return r.Client.ReleaseDistribution(ctx, params)
}
func (r RPCClient) ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) {
return r.Client.ReleaseCleanup(ctx, params)
}
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.ParseStatus(ctx)
}
@@ -278,14 +326,20 @@ type DiscoverResult struct {
DoctorHealthy *bool
Status *backendrpc.DaemonStatusReport
Doctor *backendrpc.DoctorReport
Attestation *backendrpc.DistributionAttestation
ReleaseStatus *backendrpc.ReleaseStatusReport
Distribution DistributionHealth
Snapshot *SnapshotSummary
ResourceRoot string
Index *ReleaseIndex
Warnings []string
}
// DiscoverAndIndex contacts the daemon (status first, then doctor) and builds
// a release index from paginated resource.manifest plus on-disk checks.
// DiscoverAndIndex contacts the daemon (status first, then doctor, then the
// lightweight current-release attestation) and builds a release index from
// pages bound to that attestation plus on-disk checks. Rust's attestation is
// the only release-level integrity authorization used for the production RPC
// path; release.status remains an administrative diagnostic.
//
// If resourceRootOverride is non-empty, it wins over RPC-reported roots after
// RPC health probes (still preferred for production to call status/doctor).
@@ -301,6 +355,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return nil, err
}
out.ResourceRoot = idx.ResourceRoot
out.Distribution = idx.Distribution
out.Index = idx
return out, nil
}
@@ -315,6 +370,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return out, fmt.Errorf("daemon.status failed (%v) and resource-root load failed: %w", err, loadErr)
}
out.ResourceRoot = idx.ResourceRoot
out.Distribution = idx.Distribution
out.Index = idx
return out, nil
}
@@ -334,9 +390,60 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
out.DoctorHealthy = &h
}
// An explicit root is a fixture/emergency read-only override. Keep it
// outside the production RPC release-health contract, while still probing
// daemon status and doctor first.
if resourceRootOverride != "" {
idx, loadErr := LoadIndexFromResourceRoot(resourceRootOverride)
if loadErr != nil {
return out, fmt.Errorf("resource-root override load failed: %w", loadErr)
}
idx.Source = "resource_root"
idx.RPCAvailable = true
idx.DoctorHealthy = out.DoctorHealthy
out.ResourceRoot = idx.ResourceRoot
out.Snapshot = idx.Snapshot
out.Distribution = idx.Distribution
out.Index = idx
return out, nil
}
// 3) release.attestation is the Rust-owned current-release distribution gate.
attestationBackend, ok := backend.(AttestationBackend)
if !ok {
out.Warnings = append(out.Warnings, "release.attestation: backend does not expose Rust health proof")
return emptyRPCResult(out, nil, "Rust release health is unavailable"),
fmt.Errorf("rust release health is unavailable")
}
attestation, err := attestationBackend.ReleaseAttestation(ctx)
if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("release.attestation: %v", err))
return emptyRPCResult(out, nil, "Rust release health query failed"),
fmt.Errorf("release.attestation failed: %w", err)
}
if attestation == nil {
out.Warnings = append(out.Warnings, "release.attestation: empty response")
return emptyRPCResult(out, nil, "Rust release health query returned no response"),
fmt.Errorf("release.attestation returned an empty response")
}
out.Attestation = attestation
out.Distribution = rustAttestationHealth(attestation)
if !attestation.Available ||
!attestation.Ready ||
attestation.Channel != "official" ||
attestation.IntegrityStatus != "verified" ||
attestation.VerificationGeneration == 0 ||
!attestationIsFresh(attestation) {
return emptyRPCResult(
out,
nil,
"Rust current official distribution attestation is unavailable or not ready",
), nil
}
// Catalog / resource discovery
var snapshot *SnapshotSummary
var resourceRoot string
resourceRoot := attestation.ResourceRoot
catalogAvailabilityKnown := false
catalogAvailable := false
@@ -350,6 +457,33 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
resourceRoot = root
}
}
if snapshot != nil &&
snapshot.VersionID != "" &&
attestation.ReleaseID != "" &&
snapshot.VersionID != attestation.ReleaseID {
return emptyRPCResult(
out,
snapshot,
fmt.Sprintf(
"release.attestation current ID %q does not match catalog current ID %q",
attestation.ReleaseID,
snapshot.VersionID,
),
), nil
}
if resourceRoot != "" &&
attestation.ResourceRoot != "" &&
resourceRoot != attestation.ResourceRoot {
return emptyRPCResult(
out,
snapshot,
fmt.Sprintf(
"catalog current root %q does not match attestation root %q",
resourceRoot,
attestation.ResourceRoot,
),
), nil
}
if catalogAvailabilityKnown && !catalogAvailable {
return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil
}
@@ -368,12 +502,13 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
resourceRoot = resourceRootOverride
}
if resourceRoot == "" {
out.Snapshot = snapshot
out.Snapshot = snapshotWithDistributionHealth(snapshot, out.Distribution)
out.Index = &ReleaseIndex{
Source: "rpc",
RPCAvailable: true,
DoctorHealthy: out.DoctorHealthy,
Snapshot: snapshot,
Distribution: out.Distribution,
Snapshot: snapshotWithDistributionHealth(snapshot, out.Distribution),
byRel: map[string]int{},
}
out.Warnings = append(out.Warnings, "no resource root from RPC; set --resource-root or publish a version")
@@ -393,33 +528,30 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
}
}
entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend)
entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend, attestation)
if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
// Fallback: load local manifest file under root.
idx, loadErr := LoadIndexFromResourceRoot(resourceRoot)
if loadErr != nil {
if resourceRootOverride == "" {
return emptyRPCResult(
out,
snapshot,
fmt.Sprintf("published release cannot be indexed: %v", loadErr),
), nil
}
return out, fmt.Errorf("manifest RPC and local load failed: rpc=%v local=%w", err, loadErr)
}
idx.Source = "rpc+local_manifest"
idx.RPCAvailable = true
idx.DoctorHealthy = out.DoctorHealthy
if snapshot != nil {
idx.Snapshot = snapshot
}
out.ResourceRoot = idx.ResourceRoot
out.Snapshot = idx.Snapshot
out.Index = idx
return out, nil
// Without the RPC manifest there is no evidence that the local
// snapshot matches the Rust health fact. Do not pair a fresh health
// result with a potentially stale on-disk manifest.
return emptyRPCResult(
out,
snapshot,
fmt.Sprintf("published release cannot be indexed: %v", err),
), nil
}
if rootFromManifest != "" {
if attestation.ResourceRoot != "" && rootFromManifest != attestation.ResourceRoot {
return emptyRPCResult(
out,
snapshot,
fmt.Sprintf(
"resource.manifest resource root %q does not match attestation root %q",
rootFromManifest,
attestation.ResourceRoot,
),
), nil
}
resourceRoot = rootFromManifest
}
if resourceRootOverride != "" {
@@ -434,6 +566,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
snapshot,
manifestVersion,
entries,
out.Distribution,
)
if err != nil {
if resourceRootOverride == "" {
@@ -446,18 +579,32 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return out, err
}
out.ResourceRoot = idx.ResourceRoot
out.Snapshot = snapshot
out.Snapshot = idx.Snapshot
out.Index = idx
return out, nil
}
// AttestationBackend exposes the lightweight current official health proof.
// It is intentionally separate from ReleaseStatusBackend because health
// refreshes must not require the historical release scan.
type AttestationBackend interface {
ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error)
}
func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult {
distribution := out.Distribution
if distribution.Source == "" {
distribution = unavailableRustDistributionHealth()
out.Distribution = distribution
}
snapshot = snapshotWithDistributionHealth(snapshot, distribution)
out.ResourceRoot = ""
out.Snapshot = snapshot
out.Index = &ReleaseIndex{
Source: "rpc",
RPCAvailable: true,
DoctorHealthy: out.DoctorHealthy,
Distribution: distribution,
Snapshot: snapshot,
byRel: map[string]int{},
}
@@ -467,6 +614,79 @@ func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning stri
return out
}
func rustAttestationHealth(report *backendrpc.DistributionAttestation) DistributionHealth {
health := DistributionHealth{
Source: "rust_release_attestation",
Status: "unavailable",
StatusCode: "distribution.attestation_unavailable",
IntegrityStatus: "unavailable",
}
if report == nil {
return health
}
health.Available = report.Available
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.MaxAgeSeconds = report.MaxAgeSeconds
health.Status = report.Status
health.StatusCode = report.StatusCode
health.IntegrityStatus = report.IntegrityStatus
health.Diagnostics = append([]string(nil), report.Diagnostics...)
if health.Status == "" {
health.Status = "unavailable"
}
if health.StatusCode == "" {
health.StatusCode = "distribution.attestation_unavailable"
}
if health.IntegrityStatus == "" {
health.IntegrityStatus = "unavailable"
}
return health
}
func unavailableRustDistributionHealth() DistributionHealth {
return DistributionHealth{
Source: "rust_release_attestation",
Status: "unavailable",
StatusCode: "distribution.attestation_unavailable",
IntegrityStatus: "unavailable",
}
}
func attestationIsFresh(attestation *backendrpc.DistributionAttestation) bool {
if attestation == nil || attestation.MaxAgeSeconds == 0 || attestation.VerifiedAt == nil {
return false
}
now := uint64(time.Now().Unix())
age := uint64(0)
if now > *attestation.VerifiedAt {
age = now - *attestation.VerifiedAt
}
return age <= attestation.MaxAgeSeconds
}
func snapshotWithDistributionHealth(snapshot *SnapshotSummary, health DistributionHealth) *SnapshotSummary {
if snapshot == nil {
return nil
}
updated := *snapshot
if health.Status != "" {
updated.DistributionStatus = health.Status
}
if health.StatusCode != "" {
updated.DistributionStatusCode = health.StatusCode
}
return &updated
}
func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
if len(raw) == 0 || string(raw) == "null" {
return nil, "", false
@@ -526,25 +746,106 @@ func parseCatalogAvailability(raw json.RawMessage) (bool, bool) {
return *payload.Available, true
}
func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEntry, int, string, error) {
const pageSize = 500
func fetchAllManifestEntries(
ctx context.Context,
backend Backend,
attestation *backendrpc.DistributionAttestation,
) ([]manifestEntry, int, string, error) {
return fetchAllManifestEntriesWithPageSize(ctx, backend, attestation, 500)
}
func fetchAllManifestEntriesWithPageSize(
ctx context.Context,
backend Backend,
attestation *backendrpc.DistributionAttestation,
pageSize int,
) ([]manifestEntry, int, string, error) {
if pageSize <= 0 {
return nil, 0, "", fmt.Errorf("manifest page size must be positive")
}
if attestation == nil {
return nil, 0, "", fmt.Errorf("resource.manifest requires a Rust attestation")
}
if !attestation.Available ||
!attestation.Ready ||
attestation.Channel != "official" ||
attestation.IntegrityStatus != "verified" ||
attestation.VerificationGeneration == 0 ||
!attestationIsFresh(attestation) {
return nil, 0, "", fmt.Errorf("resource.manifest attestation is not ready")
}
offset := 0
var all []manifestEntry
var version int
var root string
var version, limit int
var channel, root, releaseID, publicationIdentity, mappingIdentity, manifestIdentity string
var generation uint64
total := -1
for {
page, err := backend.ResourceManifest(ctx, offset, pageSize)
page, err := backend.ResourceManifest(ctx, backendrpc.ResourceManifestParams{
ReleaseID: attestation.ReleaseID,
ExpectedPublicationIdentity: attestation.PublicationIdentity,
ExpectedManifestIdentity: attestation.ManifestIdentity,
ExpectedVerificationGeneration: attestation.VerificationGeneration,
Offset: offset,
Limit: pageSize,
})
if err != nil {
return nil, 0, "", err
}
if page == nil {
return nil, 0, "", fmt.Errorf("resource.manifest returned nil page")
}
if !page.Available {
return nil, 0, "", fmt.Errorf("resource.manifest available=false")
}
if root == "" {
root = page.ResourceRoot
}
if version == 0 {
channel = page.Channel
releaseID = page.ReleaseID
publicationIdentity = page.PublicationIdentity
mappingIdentity = page.MappingIdentity
manifestIdentity = page.ManifestIdentity
generation = page.Generation
version = page.ManifestVersion
total = page.TotalEntries
limit = page.Limit
} else if page.ResourceRoot != root ||
page.Channel != channel ||
page.ReleaseID != releaseID ||
page.PublicationIdentity != publicationIdentity ||
page.MappingIdentity != mappingIdentity ||
page.ManifestIdentity != manifestIdentity ||
page.Generation != generation ||
page.ManifestVersion != version ||
page.TotalEntries != total ||
page.Limit != limit {
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 != 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 channel != attestation.Channel ||
releaseID != attestation.ReleaseID ||
root != attestation.ResourceRoot ||
publicationIdentity != attestation.PublicationIdentity ||
mappingIdentity != attestation.MappingIdentity ||
manifestIdentity != attestation.ManifestIdentity ||
generation != attestation.VerificationGeneration ||
total != attestation.EntryCount {
return nil, 0, "", fmt.Errorf("resource.manifest page does not match attestation")
}
for _, e := range page.Entries {
var bytes uint64
@@ -559,9 +860,19 @@ func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEn
})
}
offset += len(page.Entries)
if len(page.Entries) == 0 || offset >= page.TotalEntries {
if offset == total {
break
}
if len(page.Entries) == 0 || len(page.Entries) < page.Limit {
return nil, 0, "", fmt.Errorf("resource.manifest page has a gap before total")
}
}
if len(all) != total {
return nil, 0, "", fmt.Errorf(
"resource.manifest final entry count mismatch: entries=%d total=%d",
len(all),
total,
)
}
return all, version, root, nil
}
+55 -10
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
@@ -18,9 +19,10 @@ type Server struct {
logger *log.Logger
limiter *tokenBucketLimiter
mu sync.RWMutex
idx *ReleaseIndex
meta DiscoverResult
refreshMu sync.Mutex
mu sync.RWMutex
idx *ReleaseIndex
meta DiscoverResult
refreshInProgress bool
lastRefreshStart time.Time
@@ -51,6 +53,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/v1/bootstrap", s.handleBootstrap)
mux.HandleFunc("/v1/launcher/bootstrap", s.handleLauncherBootstrap)
mux.HandleFunc("/v1/release", s.handleRelease)
mux.HandleFunc("/v1/releases", s.handleReleaseList)
mux.HandleFunc("/v1/distribution", s.handleReleaseDistribution)
mux.HandleFunc("/v1/resources", s.handleResources)
mux.HandleFunc("/v1/server-info", s.handleServerInfoDebug)
mux.HandleFunc("/api/launcher/game/config", s.handleLauncherGameConfig)
@@ -76,10 +80,13 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff)
mux.HandleFunc("/admin/translation/memory/summary", s.handleAdminTranslationMemorySummary)
mux.HandleFunc("/admin/translation/memory/query", s.handleAdminTranslationMemoryQuery)
mux.HandleFunc("/admin/translation/memory/conflicts", s.handleAdminTranslationMemoryConflicts)
mux.HandleFunc("/admin/translation/glossary/summary", s.handleAdminGlossarySummary)
mux.HandleFunc("/admin/translation/glossary/query", s.handleAdminGlossaryQuery)
mux.HandleFunc("/admin/translation/glossary/diagnose", s.handleAdminGlossaryDiagnose)
mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus)
mux.HandleFunc("/admin/releases/status", s.handleAdminReleaseStatus)
mux.HandleFunc("/admin/releases", s.handleAdminReleaseList)
mux.HandleFunc("/admin/control/", s.handleAdminControl)
mux.HandleFunc("/admin/", s.handleAdminIndex)
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
@@ -91,10 +98,46 @@ func (s *Server) Handler() http.Handler {
// Refresh rebuilds the release index via RPC (and optional resource-root override).
func (s *Server) Refresh(ctx context.Context) error {
// Serialize refreshes so an older, slower RPC response cannot replace a
// newer snapshot and so refresh diagnostics describe one attempt at a time.
s.refreshMu.Lock()
defer s.refreshMu.Unlock()
started := s.beginRefresh()
result, err := DiscoverAndIndex(ctx, s.backend, s.cfg.ResourceRoot)
if err != nil {
s.finishRefresh(started, err, nil)
s.mu.Lock()
warnings := []string(nil)
if result != nil {
s.meta = *result
warnings = append(warnings, result.Warnings...)
} else {
s.meta = DiscoverResult{}
}
warnings = append(warnings, fmt.Sprintf("refresh: %v", err))
s.idx = &ReleaseIndex{
Source: "empty",
Distribution: unavailableRustDistributionHealth(),
byRel: map[string]int{},
}
s.meta.Index = s.idx
s.meta.ResourceRoot = ""
s.meta.Distribution = s.idx.Distribution
s.finishRefreshLocked(started, err, warnings)
s.mu.Unlock()
return err
}
if result == nil || result.Index == nil {
err := fmt.Errorf("refresh returned no release index")
s.mu.Lock()
s.idx = &ReleaseIndex{
Source: "empty",
Distribution: unavailableRustDistributionHealth(),
byRel: map[string]int{},
}
s.meta = DiscoverResult{Index: s.idx, Distribution: s.idx.Distribution}
s.finishRefreshLocked(started, err, []string{err.Error()})
s.mu.Unlock()
return err
}
s.mu.Lock()
@@ -132,6 +175,8 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
"/v1/bootstrap",
"/v1/launcher/bootstrap",
"/v1/release",
"/v1/releases",
"/v1/distribution",
"/v1/resources",
"/v1/server-info",
"/api/launcher/game/config",
@@ -158,10 +203,13 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
"/admin/translation/handoff",
"/admin/translation/memory/summary",
"/admin/translation/memory/query",
"/admin/translation/memory/conflicts",
"/admin/translation/glossary/summary",
"/admin/translation/glossary/query",
"/admin/translation/glossary/diagnose",
"/admin/translation/status",
"/admin/releases/status",
"/admin/releases",
"/admin/control/{action}",
},
})
@@ -221,6 +269,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
Release: sum.Snapshot,
ResourceRoot: sum.ResourceRoot,
Source: sum.Source,
Distribution: sum.Distribution,
ManifestVersion: sum.ManifestVersion,
EntryCount: sum.EntryCount,
PresentCount: sum.PresentCount,
@@ -279,6 +328,7 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
"missing_count": sum.MissingCount,
"source": sum.Source,
"warnings": meta.Warnings,
"distribution": sum.Distribution,
// Database/redis are reserved config surface for a normal API process.
"database_configured": s.cfg.DatabaseURL != "",
"redis_configured": s.cfg.RedisURL != "",
@@ -308,6 +358,7 @@ func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
"present_count": sum.PresentCount,
"missing_count": sum.MissingCount,
"source": sum.Source,
"distribution": sum.Distribution,
"refresh": s.refreshSnapshot(),
}
if r.Method == http.MethodHead {
@@ -422,12 +473,6 @@ func (s *Server) beginRefresh() time.Time {
return now
}
func (s *Server) finishRefresh(started time.Time, err error, warnings []string) {
s.mu.Lock()
defer s.mu.Unlock()
s.finishRefreshLocked(started, err, warnings)
}
func (s *Server) finishRefreshLocked(started time.Time, err error, warnings []string) {
now := time.Now()
s.refreshInProgress = false
+6 -2
View File
@@ -7,10 +7,14 @@ contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本
覆盖范围:
- `catalog.status` 可用与不可用响应。
- `resource.manifest` 第一页分页响应
- `resource.manifest` 第一页分页响应,包含 release/publication/mapping/manifest
identity 和 generation 绑定字段;生产请求必须回传 attestation 的
`expected_verification_generation`
- `release.attestation` 当前 official health/publication proof,默认 fixture freshness
window 为 7260 秒(默认 3600 秒验证周期和 60 秒失败重试周期)。
- 对应 release 的 `official-sync-snapshot.json`
- `launcher_metadata``game_main_config_bootstrap` 的 Go mirror 解码。
- Rust Glossary V1 query 响应,覆盖 alias、approved review、source provenance 和完整 history。
- Rust Glossary domain/feature contract V1、SQLite persistence schema V2 的 query 响应,覆盖 alias、approved review、source provenance 和完整 history。
这些 fixture 只用于 schema / mirror 回归,不代表真实资源版本,也不替代 live
daemon socket 或完整发布切换验证。
+18
View File
@@ -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": 7260,
"publication_identity": "${PUBLICATION_IDENTITY}",
"ready": true,
"release_id": "${VERSION_ID}",
"resource_root": "${RESOURCE_ROOT}",
"status": "ready",
"status_code": "distribution.ready",
"verification_generation": 7,
"verified_at": 1000
}
@@ -1,5 +1,6 @@
{
"available": true,
"channel": "official",
"entries": [
{
"blake3": "0000000000000000000000000000000000000000000000000000000000000000",
@@ -14,9 +15,14 @@
"url": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.hash"
}
],
"generation": 7,
"limit": 2,
"manifest_identity": "${MANIFEST_IDENTITY}",
"manifest_version": 1,
"mapping_identity": "${MAPPING_IDENTITY}",
"offset": 0,
"publication_identity": "${PUBLICATION_IDENTITY}",
"release_id": "${VERSION_ID}",
"resource_root": "${RESOURCE_ROOT}",
"total_entries": 2
}
+258 -20
View File
@@ -118,7 +118,7 @@ func (c *Client) callEnvelope(ctx context.Context, method string, params any) (*
if err != nil {
return nil, err
}
defer conn.Close()
defer func() { _ = conn.Close() }()
if deadline, ok := c.deadline(ctx); ok {
_ = conn.SetDeadline(deadline)
@@ -182,6 +182,118 @@ type pageParam struct {
Limit int `json:"limit"`
}
// ReleaseListParams selects one Rust-owned release namespace.
type ReleaseListParams struct {
Channel string `json:"channel,omitempty"`
}
// ReleaseDistributionParams selects a verified release for distribution.
type ReleaseDistributionParams struct {
Channel string `json:"channel,omitempty"`
ReleaseID string `json:"release_id,omitempty"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
Destination string `json:"destination,omitempty"`
}
// ReleaseCleanupParams controls the dry-run/execute cleanup pair.
type ReleaseCleanupParams struct {
Execute bool `json:"execute,omitempty"`
PlanID string `json:"plan_id,omitempty"`
}
// ReleaseSummary mirrors Rust's dual-release historical summary.
type ReleaseSummary struct {
Channel string `json:"channel"`
ID string `json:"id"`
Path string `json:"path"`
SourceOfficialReleaseID string `json:"source_official_release_id,omitempty"`
CreatedUnixSeconds *uint64 `json:"created_unix_seconds,omitempty"`
PublishedUnixSeconds *uint64 `json:"published_unix_seconds,omitempty"`
Current bool `json:"current"`
CurrentPointerValid bool `json:"current_pointer_valid"`
RollbackAvailable bool `json:"rollback_available"`
Stale bool `json:"stale"`
Damaged bool `json:"damaged"`
Referenced bool `json:"referenced"`
Unknown bool `json:"unknown"`
Lifecycle string `json:"lifecycle"`
ManifestContractStatus string `json:"manifest_contract_status"`
ArtifactIntegrityStatus string `json:"artifact_integrity_status"`
DistributionIntegrityStatus string `json:"distribution_integrity_status"`
Legacy bool `json:"legacy"`
RollbackPreviousReleaseID string `json:"rollback_previous_release_id,omitempty"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
// ReleaseStatusReport is the unified official/localized release view.
type ReleaseStatusReport struct {
Status string `json:"status"`
StatusCode string `json:"status_code"`
OfficialCurrentReleaseID string `json:"official_current_release_id,omitempty"`
LocalizedCurrentReleaseID string `json:"localized_current_release_id,omitempty"`
LocalizedSourceOfficialID string `json:"localized_source_official_release_id,omitempty"`
CurrentReleasesMatch bool `json:"current_releases_match"`
DefaultDistributionChannel string `json:"default_distribution_channel"`
OfficialDistributionReady bool `json:"official_distribution_ready"`
LocalizedDistributionReady bool `json:"localized_distribution_ready"`
Releases []ReleaseSummary `json:"releases"`
}
// ReleaseListReport is the filtered historical release response.
type ReleaseListReport struct {
Status string `json:"status"`
StatusCode string `json:"status_code"`
Channel string `json:"channel,omitempty"`
Releases []ReleaseSummary `json:"releases"`
}
// ReleaseDistributionEntry is one Rust-verified resource manifest entry.
type ReleaseDistributionEntry struct {
URL string `json:"url"`
Destination string `json:"destination"`
Bytes uint64 `json:"bytes"`
BLAKE3 string `json:"blake3"`
}
// ReleaseDistributionPage is a typed page for one selected release.
type ReleaseDistributionPage struct {
Available bool `json:"available"`
Channel string `json:"channel"`
ReleaseID string `json:"release_id,omitempty"`
ResourceRoot string `json:"resource_root,omitempty"`
SourceOfficialReleaseID string `json:"source_official_release_id,omitempty"`
Current bool `json:"current"`
Status string `json:"status"`
StatusCode string `json:"status_code"`
ArtifactIntegrityStatus string `json:"artifact_integrity_status"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Entries []ReleaseDistributionEntry `json:"entries"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
// ReleaseCleanupEntry is one retained or removable cleanup observation.
type ReleaseCleanupEntry struct {
Channel string `json:"channel"`
ID string `json:"id"`
Path string `json:"path"`
Candidate bool `json:"candidate"`
RetainReasons []string `json:"retain_reasons,omitempty"`
BlockingReferences []string `json:"blocking_references,omitempty"`
}
// ReleaseCleanupReport is the dry-run or execute result.
type ReleaseCleanupReport struct {
Execute bool `json:"execute"`
PlanID string `json:"plan_id"`
Revalidated bool `json:"revalidated"`
Entries []ReleaseCleanupEntry `json:"entries"`
Removed []string `json:"removed"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
type tailParam struct {
Tail int `json:"tail"`
}
@@ -367,9 +479,10 @@ const (
type TranslationMemoryMatchKind string
const (
TranslationMemoryMatchStrongExact TranslationMemoryMatchKind = "strong_exact"
TranslationMemoryMatchCandidateExact TranslationMemoryMatchKind = "candidate_exact"
TranslationMemoryMatchSourceOnly TranslationMemoryMatchKind = "source_only"
TranslationMemoryMatchStrongExact TranslationMemoryMatchKind = "strong_exact"
TranslationMemoryMatchTrustedConflict TranslationMemoryMatchKind = "trusted_conflict"
TranslationMemoryMatchCandidateExact TranslationMemoryMatchKind = "candidate_exact"
TranslationMemoryMatchSourceOnly TranslationMemoryMatchKind = "source_only"
)
// TranslationMemorySummaryParams selects an optional Rust-owned TM database.
@@ -392,16 +505,19 @@ type TranslationMemoryConfirmParams struct {
RecordID string `json:"record_id"`
Reviewer string `json:"reviewer"`
Reason string `json:"reason,omitempty"`
SupersedeRecordID string `json:"supersede_record_id,omitempty"`
}
// TranslationMemorySummary mirrors translation.memory.summary data.
type TranslationMemorySummary struct {
SchemaVersion uint64 `json:"schema_version"`
RecordCount uint64 `json:"record_count"`
TrustedCount uint64 `json:"trusted_count"`
CandidateCount uint64 `json:"candidate_count"`
SupersededCount uint64 `json:"superseded_count"`
RejectedCount uint64 `json:"rejected_count"`
SchemaVersion uint64 `json:"schema_version"`
RecordCount uint64 `json:"record_count"`
TrustedCount uint64 `json:"trusted_count"`
CandidateCount uint64 `json:"candidate_count"`
SupersededCount uint64 `json:"superseded_count"`
RejectedCount uint64 `json:"rejected_count"`
TrustedConflictGroupCount uint64 `json:"trusted_conflict_group_count"`
CurrentTrustedCount uint64 `json:"current_trusted_count,omitempty"`
}
// TranslationMemorySummaryReport distinguishes a missing database from an
@@ -479,6 +595,47 @@ type TranslationMemoryConfirmReport struct {
Entry TranslationMemoryEntry `json:"entry"`
}
// TranslationMemoryConflict groups all records sharing one exact source
// identity when more than one current Trusted record exists.
type TranslationMemoryConflict struct {
SourceText string `json:"source_text"`
SourceHash string `json:"source_hash"`
SourceContext TranslationMemoryContext `json:"source_context,omitempty"`
SourceContextHash string `json:"source_context_hash"`
TrustedRecordIDs []string `json:"trusted_record_ids"`
Records []TranslationMemoryEntry `json:"records"`
}
// TranslationMemoryConflictsParams lists current Trusted conflicts.
type TranslationMemoryConflictsParams struct {
TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
Limit *uint64 `json:"limit,omitempty"`
}
// TranslationMemoryConflictsReport mirrors translation.memory.conflicts data.
type TranslationMemoryConflictsReport struct {
Available bool `json:"available"`
Path string `json:"path"`
Conflicts []TranslationMemoryConflict `json:"conflicts"`
Reason string `json:"reason,omitempty"`
}
// TranslationMemoryResolveConflictParams resolves one exact-identity conflict.
type TranslationMemoryResolveConflictParams struct {
TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
WinnerRecordID string `json:"winner_record_id"`
ExpectedTrustedRecordIDs []string `json:"expected_trusted_record_ids"`
Reviewer string `json:"reviewer"`
Reason string `json:"reason"`
}
// TranslationMemoryResolveConflictReport mirrors the conflict resolution result.
type TranslationMemoryResolveConflictReport struct {
Available bool `json:"available"`
Path string `json:"path"`
Entry TranslationMemoryEntry `json:"entry"`
}
// GlossaryReviewStatus is the Rust-owned term review state.
type GlossaryReviewStatus string
@@ -513,6 +670,7 @@ type GlossaryTermSnapshot struct {
// GlossaryOverride records explicit human approval for a deviation.
type GlossaryOverride struct {
QAIdentity string `json:"qa_identity"`
Reviewer string `json:"reviewer"`
Reason string `json:"reason"`
Provenance string `json:"provenance"`
@@ -636,10 +794,12 @@ type GlossaryDiagnoseReport struct {
}
// LocalizedPublishParams selects the source of one localized release
// publication. TranslationFile and FromWorker are mutually exclusive.
// publication. Exactly one of TranslationFile, FromWorker, or PatchManifest
// must be set.
type LocalizedPublishParams struct {
TranslationFile string `json:"translation_file,omitempty"`
FromWorker bool `json:"from_worker,omitempty"`
PatchManifest string `json:"patch_manifest,omitempty"`
LocalizedReleaseID string `json:"localized_release_id,omitempty"`
Force bool `json:"force,omitempty"`
}
@@ -762,14 +922,30 @@ type ResourceManifestEntry struct {
BLAKE3 string `json:"blake3,omitempty"`
}
// ResourceManifestParams binds every page to one attested official release.
type ResourceManifestParams struct {
ReleaseID string `json:"release_id,omitempty"`
ExpectedPublicationIdentity string `json:"expected_publication_identity,omitempty"`
ExpectedManifestIdentity string `json:"expected_manifest_identity,omitempty"`
ExpectedVerificationGeneration uint64 `json:"expected_verification_generation"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
type ResourceManifestPage struct {
Available bool `json:"available"`
ResourceRoot string `json:"resource_root,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
TotalEntries int `json:"total_entries,omitempty"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
Entries []ResourceManifestEntry `json:"entries,omitempty"`
Available bool `json:"available"`
Channel string `json:"channel,omitempty"`
ReleaseID string `json:"release_id,omitempty"`
ResourceRoot string `json:"resource_root,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
PublicationIdentity string `json:"publication_identity,omitempty"`
MappingIdentity string `json:"mapping_identity,omitempty"`
ManifestIdentity string `json:"manifest_identity,omitempty"`
Generation uint64 `json:"generation"`
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) {
@@ -838,9 +1014,35 @@ func (c *Client) ResourceRepair(ctx context.Context) (*TaskAccepted, error) {
return &out, err
}
func (c *Client) ResourceManifest(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) {
// DistributionAttestation is the Rust-owned current official health proof.
type DistributionAttestation struct {
Available bool `json:"available"`
Channel string `json:"channel"`
ReleaseID string `json:"release_id"`
ResourceRoot string `json:"resource_root"`
PublicationIdentity string `json:"publication_identity"`
MappingIdentity string `json:"mapping_identity"`
ManifestIdentity string `json:"manifest_identity"`
EntryCount int `json:"entry_count"`
IntegrityStatus string `json:"integrity_status"`
Status string `json:"status"`
StatusCode string `json:"status_code"`
Ready bool `json:"ready"`
VerificationGeneration uint64 `json:"verification_generation"`
VerifiedAt *uint64 `json:"verified_at,omitempty"`
MaxAgeSeconds uint64 `json:"max_age_seconds"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
func (c *Client) ReleaseAttestation(ctx context.Context) (*DistributionAttestation, error) {
var out DistributionAttestation
_, err := c.Call(ctx, "release.attestation", nil, &out)
return &out, err
}
func (c *Client) ResourceManifest(ctx context.Context, params ResourceManifestParams) (*ResourceManifestPage, error) {
var out ResourceManifestPage
_, err := c.Call(ctx, "resource.manifest", pageParam{Offset: offset, Limit: limit}, &out)
_, err := c.Call(ctx, "resource.manifest", params, &out)
return &out, err
}
@@ -910,6 +1112,18 @@ func (c *Client) TranslationMemoryConfirm(ctx context.Context, params Translatio
return &out, err
}
func (c *Client) TranslationMemoryConflicts(ctx context.Context, params TranslationMemoryConflictsParams) (*TranslationMemoryConflictsReport, error) {
var out TranslationMemoryConflictsReport
_, err := c.Call(ctx, "translation.memory.conflicts", params, &out)
return &out, err
}
func (c *Client) TranslationMemoryResolveConflict(ctx context.Context, params TranslationMemoryResolveConflictParams) (*TranslationMemoryResolveConflictReport, error) {
var out TranslationMemoryResolveConflictReport
_, err := c.Call(ctx, "translation.memory.resolve_conflict", params, &out)
return &out, err
}
func (c *Client) GlossarySummary(ctx context.Context, params GlossarySummaryParams) (*GlossarySummaryReport, error) {
var out GlossarySummaryReport
_, err := c.Call(ctx, "translation.glossary.summary", params, &out)
@@ -978,6 +1192,30 @@ func (c *Client) CatalogDiff(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.diff", nil)
}
func (c *Client) ReleaseStatus(ctx context.Context) (*ReleaseStatusReport, error) {
var out ReleaseStatusReport
_, err := c.Call(ctx, "release.status", nil, &out)
return &out, err
}
func (c *Client) ReleaseList(ctx context.Context, params ReleaseListParams) (*ReleaseListReport, error) {
var out ReleaseListReport
_, err := c.Call(ctx, "release.list", params, &out)
return &out, err
}
func (c *Client) ReleaseDistribution(ctx context.Context, params ReleaseDistributionParams) (*ReleaseDistributionPage, error) {
var out ReleaseDistributionPage
_, err := c.Call(ctx, "release.distribution", params, &out)
return &out, err
}
func (c *Client) ReleaseCleanup(ctx context.Context, params ReleaseCleanupParams) (*ReleaseCleanupReport, error) {
var out ReleaseCleanupReport
_, err := c.Call(ctx, "release.cleanup", params, &out)
return &out, err
}
func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out)
+299 -10
View File
@@ -38,7 +38,7 @@ func newTestClient(t *testing.T, handler func(t *testing.T, req testRequest) tes
client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
clientConn, serverConn := net.Pipe()
go func(conn net.Conn) {
defer conn.Close()
defer func() { _ = conn.Close() }()
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return
@@ -88,6 +88,94 @@ func TestResourceRepairQueuesTask(t *testing.T) {
}
}
func TestReleaseDistributionUsesTypedRPCContract(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "release.distribution" {
t.Fatalf("method = %s", req.Method)
}
var params ReleaseDistributionParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.Channel != "localized" || params.ReleaseID != "localized-1" || params.Offset != 2 || params.Limit != 10 {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
Data: map[string]any{
"available": true,
"channel": "localized",
"release_id": "localized-1",
"resource_root": "/tmp/localized",
"total": 3,
"offset": 2,
"limit": 10,
"entries": []any{map[string]any{
"url": "https://example.invalid/data.bin",
"destination": "host/data.bin",
"bytes": 4,
"blake3": "abcd",
}},
},
},
}
})
page, err := client.ReleaseDistribution(context.Background(), ReleaseDistributionParams{
Channel: "localized",
ReleaseID: "localized-1",
Offset: 2,
Limit: 10,
})
if err != nil {
t.Fatalf("ReleaseDistribution error: %v", err)
}
if !page.Available || page.ResourceRoot != "/tmp/localized" || len(page.Entries) != 1 {
t.Fatalf("page = %#v", page)
}
}
func TestReleaseStatusUsesWholeReleaseHealthFact(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "release.status" {
t.Fatalf("method = %s", req.Method)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
Data: map[string]any{
"status": "blocked",
"status_code": "distribution.blocked",
"default_distribution_channel": "official",
"official_current_release_id": "official-1",
"official_distribution_ready": false,
"releases": []any{map[string]any{
"channel": "official",
"id": "official-1",
"current": true,
"distribution_integrity_status": "invalid",
}},
},
},
}
})
report, err := client.ReleaseStatus(context.Background())
if err != nil {
t.Fatalf("ReleaseStatus error: %v", err)
}
if report.StatusCode != "distribution.blocked" ||
report.OfficialDistributionReady ||
report.OfficialCurrentReleaseID != "official-1" ||
len(report.Releases) != 1 ||
report.Releases[0].DistributionIntegrityStatus != "invalid" {
t.Fatalf("report = %#v", report)
}
}
func TestDaemonRestartSendsControlMethod(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "daemon.restart" {
@@ -161,6 +249,103 @@ 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": 7260,
},
},
}
})
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, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.ReleaseID != "official-a" ||
params.ExpectedPublicationIdentity != "odp-v1-publication-a" ||
params.ExpectedManifestIdentity != "manifest-a" ||
params.ExpectedVerificationGeneration != 7 ||
params.Offset != 1 || params.Limit != 100 {
t.Fatalf("params = %#v", params)
}
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",
ExpectedVerificationGeneration: 7,
Offset: 1,
Limit: 100,
})
if err != nil {
t.Fatalf("ResourceManifest error: %v", err)
}
if page.ReleaseID != "official-a" || page.ManifestIdentity != "manifest-a" ||
page.Generation != 7 {
t.Fatalf("page = %#v", page)
}
}
func TestUnityFSPatchFieldSendsTaggedReplacement(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "unityfs.patch_field" {
@@ -464,6 +649,7 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) {
SourceText: "source",
TranslatedText: "译文",
GlossaryOverride: &GlossaryOverride{
QAIdentity: "gqa-v1-test",
Reviewer: "reviewer",
Reason: "approved deviation",
Provenance: "manual-review",
@@ -570,14 +756,15 @@ func TestTranslationMemoryTypedContract(t *testing.T) {
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/translation-memory.sqlite",
"schema_version": 1,
"schema_version": 2,
"summary": map[string]any{
"schema_version": 1,
"record_count": 3,
"trusted_count": 1,
"candidate_count": 1,
"superseded_count": 1,
"rejected_count": 0,
"schema_version": 2,
"record_count": 3,
"trusted_count": 1,
"candidate_count": 1,
"superseded_count": 1,
"rejected_count": 0,
"current_trusted_count": 1,
},
},
},
@@ -673,6 +860,50 @@ func TestTranslationMemoryTypedContract(t *testing.T) {
},
},
}
case "translation.memory.conflicts":
var params TranslationMemoryConflictsParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode conflicts params: %v", err)
}
if params.TranslationMemoryPath != "/var/lib/bat/translation-memory.sqlite" ||
params.Limit == nil || *params.Limit != limit {
t.Fatalf("conflicts params=%#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-tm-conflicts",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/translation-memory.sqlite",
"conflicts": []any{},
},
},
}
case "translation.memory.resolve_conflict":
var params TranslationMemoryResolveConflictParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode resolve params: %v", err)
}
if params.WinnerRecordID != "tm-record-1" ||
len(params.ExpectedTrustedRecordIDs) != 2 ||
params.ExpectedTrustedRecordIDs[1] != "tm-record-2" ||
params.Reviewer != "reviewer" ||
params.Reason != "selected" {
t.Fatalf("resolve params=%#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-tm-resolve",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/translation-memory.sqlite",
"entry": map[string]any{
"record_id": "tm-record-1",
"trust_status": "trusted",
},
},
},
}
default:
t.Fatalf("unexpected method %q", req.Method)
return testResponse{}
@@ -729,6 +960,32 @@ func TestTranslationMemoryTypedContract(t *testing.T) {
confirmed.Entry.RecordID != "tm-record-1" {
t.Fatalf("confirmed=%#v", confirmed)
}
conflicts, err := client.TranslationMemoryConflicts(context.Background(), TranslationMemoryConflictsParams{
TranslationMemoryPath: "/var/lib/bat/translation-memory.sqlite",
Limit: &limit,
})
if err != nil {
t.Fatalf("TranslationMemoryConflicts error: %v", err)
}
if !conflicts.Available || len(conflicts.Conflicts) != 0 {
t.Fatalf("conflicts=%#v", conflicts)
}
resolved, err := client.TranslationMemoryResolveConflict(context.Background(), TranslationMemoryResolveConflictParams{
TranslationMemoryPath: "/var/lib/bat/translation-memory.sqlite",
WinnerRecordID: "tm-record-1",
ExpectedTrustedRecordIDs: []string{"tm-record-1", "tm-record-2"},
Reviewer: "reviewer",
Reason: "selected",
})
if err != nil {
t.Fatalf("TranslationMemoryResolveConflict error: %v", err)
}
if !resolved.Available || resolved.Entry.RecordID != "tm-record-1" ||
resolved.Entry.TrustStatus != TranslationMemoryStatusTrusted {
t.Fatalf("resolved=%#v", resolved)
}
}
func TestTranslationProofreadUsesRustMethod(t *testing.T) {
@@ -796,6 +1053,38 @@ func TestLocalizedPublishSendsWorkerSourceAndReleaseOptions(t *testing.T) {
}
}
func TestLocalizedPublishSendsPatchManifestSource(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
var params LocalizedPublishParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.PatchManifest != "/tmp/patch-manifest.json" ||
params.TranslationFile != "" || params.FromWorker {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-localized-patch-manifest",
Data: map[string]any{"status": "published"},
},
}
})
raw, err := client.LocalizedPublish(context.Background(), LocalizedPublishParams{
PatchManifest: "/tmp/patch-manifest.json",
LocalizedReleaseID: "localized-v1",
})
if err != nil {
t.Fatalf("LocalizedPublish error: %v", err)
}
if !json.Valid(raw) {
t.Fatalf("invalid raw JSON: %s", string(raw))
}
}
func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "localized.rollback" {
@@ -846,9 +1135,9 @@ func TestGlossaryTypedContract(t *testing.T) {
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"schema_version": 1,
"schema_version": 2,
"summary": map[string]any{
"schema_version": 1,
"schema_version": 2,
"term_count": 2,
"approved_count": 1,
"draft_count": 1,
+35 -4
View File
@@ -141,12 +141,43 @@ require_contains "docs/reports/CURRENT_GAPS.md" "daemon.clean-stable"
require_contains "docs/reference/rpc-backend-api.md" "daemon.restart"
require_contains "docs/reference/rpc-backend-api.md" "daemon.clean-stable"
require_contains "docs/reference/rpc-backend-api.md" "localized_release_status"
require_contains "docs/reference/rpc-backend-api.md" "official_distribution_ready"
require_contains "docs/reference/rpc-backend-api.md" "release.attestation"
require_contains "docs/reference/rpc-backend-api.md" "expected_manifest_identity"
require_contains "docs/reference/rpc-backend-api.md" "expected_verification_generation"
require_contains "docs/architecture/resource-release-layout.md" "release.attestation"
require_contains "Makefile" "check-docs:"
require_contains ".gitea/workflows/bat.yml" "make check-docs"
require_contains ".gitea/workflows/bat.yml" "make test-go-api"
require_contains ".gitea/workflows/bat.yml" "go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/..."
require_contains ".gitea/workflows/bat.yml" "go build -o /tmp/bat-api ./cmd/bat-api"
require_contains "Makefile" "check-go-format:"
require_contains "Makefile" "format: fmt"
require_contains "Makefile" "ci-check:"
require_contains "Makefile" "ci: ci-check"
require_file "scripts/ci-check.sh"
require_file "scripts/check-go-format.sh"
require_file "scripts/ci-versions.sh"
require_contains "scripts/ci-check.sh" "RUN required:"
require_contains "scripts/ci-check.sh" "make check-go-format"
require_contains "scripts/ci-check.sh" "Go lint version"
require_contains "scripts/ci-check.sh" "GOLANGCI_LINT_VERSION"
require_contains "scripts/ci-versions.sh" 'GOLANGCI_LINT_VERSION="2.12.2"'
require_contains "docs/guides/development.md" "golangci-lint --version"
require_contains "docs/guides/development.md" "required gate"
require_contains "docs/guides/development.md" "make ci-check"
require_contains "docs/guides/development.md" "唯一完整 required quality gate"
if grep -RIEq --exclude=check-doc-status.sh --exclude-dir=.git --exclude-dir=archive --exclude-dir=historical \
--exclude-dir=target --exclude-dir=bin \
-- '可选 lint|optional lint|optional golangci-lint' .; then
fail "current documentation still describes Go lint as optional"
fi
if grep -Fq "ci: fmt" Makefile; then
fail "Makefile ci target must not run the mutating fmt target"
fi
if grep -RIEq --include='*.md' --exclude-dir=.git --exclude-dir=archive --exclude-dir=historical \
--exclude-dir=target --exclude-dir=bin \
-- '自托管 Gitea|Gitea[[:space:]]+(runner|CI|workflow)|self-hosted|self hosted|\.gitea/workflows' \
README.md CURRENT_STATUS.md TODO.md docs PROJECT_PLAN.md AGENTS.md DOCS_INDEX.md 2>/dev/null; then
fail "current documentation still describes a Gitea/self-hosted CI runner"
fi
require_contains "Makefile" "cargo clippy --workspace --all-targets -- -D warnings"
openapi_tmp="$(mktemp)"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
: "${GOCACHE:=/tmp/bat-go-cache}"
export GOCACHE
mapfile -t go_dirs < <(go list -f '{{.Dir}}' ./...)
go_files=()
for dir in "${go_dirs[@]}"; do
while IFS= read -r -d '' file; do
go_files+=("${file}")
done < <(find "${dir}" -maxdepth 1 -type f -name '*.go' -print0)
done
if ((${#go_files[@]} == 0)); then
exit 0
fi
formatted="$(gofmt -l "${go_files[@]}")"
if [[ -n "${formatted}" ]]; then
printf 'gofmt required; files need formatting:\n%s\n' "${formatted}" >&2
exit 1
fi
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
: "${GOCACHE:=/tmp/bat-go-cache}"
export GOCACHE
: "${XDG_CACHE_HOME:=/tmp/bat-xdg-cache}"
export XDG_CACHE_HOME
source "${repo_root}/scripts/ci-versions.sh"
export GOLANGCI_LINT_VERSION
run_required() {
local name="$1"
shift
printf 'RUN required: %s\n' "${name}"
"$@"
printf 'PASS required: %s\n' "${name}"
}
run_required "Rust formatting" cargo fmt --all -- --check
run_required "Go formatting" make check-go-format
run_required "Rust check" cargo check --workspace --locked
run_required "Rust release build" cargo build --workspace --release --locked
run_required "Rust clippy" cargo clippy --workspace --all-targets --locked -- -D warnings
run_required "Rust tests" cargo test --workspace --locked
run_required "Go API tests" go test ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
run_required "Go API vet" go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
run_required "Go API build" go build -o /tmp/bat-api ./cmd/bat-api
run_required "Go lint version" bash -c '
source scripts/ci-versions.sh
command -v golangci-lint >/dev/null 2>&1 ||
{ printf "golangci-lint %s is required but not installed\n" "${GOLANGCI_LINT_VERSION}" >&2; exit 1; }
actual="$(golangci_lint_actual_version)"
if [[ "${actual}" != "${GOLANGCI_LINT_VERSION}" ]]; then
printf "golangci-lint version mismatch: required=%s actual=%s\n" \
"${GOLANGCI_LINT_VERSION}" "${actual:-unknown}" >&2
exit 1
fi
'
run_required "Go lint" golangci-lint run ./...
run_required "Documentation, OpenAPI, and contract checks" make check-docs
printf 'all required check-only gates passed\n'
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Canonical versions for required local quality-gate tools.
GOLANGCI_LINT_VERSION="2.12.2"
golangci_lint_actual_version() {
golangci-lint version 2>/dev/null |
sed -n 's/.*has version \([^ ]*\).*/\1/p'
}