mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
Compare commits
16
Commits
0275a890bc
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff1adb91ee
|
||
|
|
e486f1aaaa
|
||
|
|
7f7d757f15
|
||
|
|
99355effe4
|
||
|
|
13b0bd5b45
|
||
|
|
c17904ee1c
|
||
|
|
32fc64fa83
|
||
|
|
37d49c9793
|
||
|
|
5bae90cb14
|
||
|
|
786b739f99
|
||
|
|
f2c20367a6
|
||
|
|
30d1cd77e8
|
||
|
|
8d57a63697
|
||
|
|
8a77502272
|
||
|
|
69b6e36bf0
|
||
|
|
68c6c91b1e
|
@@ -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
|
||||
@@ -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
@@ -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 支持本地 fixture;up-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 都执行确定性 QA;blocking 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 transaction:Translation Tasks 从 V1
|
||||
按显式 `v1 -> v2` step 迁移,当前版本为 V2;Translation Memory persistence schema
|
||||
当前版本为 V2,Glossary 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 closed;migration 失败会 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 field 和 managed-reference string field 的 localized patch 发布闭环,真实复杂版本差异、整体 AssetBundle 重打包和通用 Patch 仍未实现。
|
||||
- `crates/bat-assetbundle` 已具备 UnityFS 容器、对象表、TypeTree 元数据、基础字段读取、TextAsset 和 TextUnit 提取;UnityFS 容器已补充总大小、计数、路径、重复 directory、LZMA 和边界校验,并通过 UnityPy 真实 bundle 隔离回归。对当前真实/合成回归覆盖的结构,TextAsset、TypeTree string field、managed-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 fixture;generic 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 已有独立 staging、manifest、current、状态校验和显式 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/Text、UnityFS 结构及 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 fixture,Go mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.*`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
|
||||
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取 Rust 当前 official `release.attestation`,再按 release/publication/mapping/manifest identity 和 verification generation 绑定读取 `resource.manifest`,不在配置里写死资源目录;轻量 attestation 只读取 current、canonical versioned root、publication anchor、manifest 元数据和 freshness,不遍历历史 release 或计算资源文件 BLAKE3。Rust watch 周期负责 current 本地 manifest 验证并更新 attestation,默认 freshness window 为 `2 * 3600 + 60 = 7260` 秒;HTTP readiness 还要求 Go 分页快照完整且本地路径安全;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest`、`official-sync-snapshot.json` 和 Glossary query contract fixture,Go mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm/conflicts/resolve_conflict`、`translation.glossary.*`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
|
||||
|
||||
生产要求:
|
||||
|
||||
@@ -304,7 +350,7 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
|
||||
后续工程顺序:
|
||||
|
||||
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
|
||||
2. 继续通用 Patch:manifest 驱动、双 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
@@ -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",
|
||||
|
||||
@@ -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) | 510–590 | 1.50 | -0.182px | Sub-labels, category headers |
|
||||
| Caption | Inter Variable | 13px (0.81rem) | 400–510 | 1.50 | -0.13px | Metadata, timestamps |
|
||||
| Label | Inter Variable | 12px (0.75rem) | 400–590 | 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) | 400–510 | 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) | 400–510 | 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 13–14px 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: 2–3 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.02–0.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 | 600–640px | Standard mobile layout |
|
||||
| Tablet | 640–768px | Two-column grids begin |
|
||||
| Desktop Small | 768–1024px | Full card grids, expanded padding |
|
||||
| Desktop | 1024–1280px | Standard desktop, full navigation |
|
||||
| Large Desktop | >1280px | Full layout, generous margins |
|
||||
|
||||
### Touch Targets
|
||||
- Buttons use comfortable padding with 6px radius minimum
|
||||
- Navigation links at 13–14px 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
@@ -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` 均不能把计划项提升为已实现事实;历史报告只用于解释演进过程。
|
||||
|
||||
@@ -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
@@ -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 contract,Go 侧仅代理。
|
||||
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 schema:source raw/hash、translation、完整 context、candidate/trusted 和 provenance。
|
||||
2. 已实现 raw source + 完整 context exact match;模糊匹配和完整导入导出仍待实现。Glossary V1 已作为独立项目级 SQLite 资产接入 approved review、scope/alias/priority、provider constraints、确定性 QA 和显式 override。
|
||||
3. 已实现显式 per-record confirm;Glossary V1 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。
|
||||
4. 已实现 `bat i18n memory summary|query|confirm` 与对应 Rust RPC。
|
||||
1. Translation Memory persistence schema V2 已使用项目级 SQLite schema:source raw/hash、translation、完整 context、candidate/trusted、provenance、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 V1(SQLite 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。
|
||||
|
||||
@@ -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 V1;Glossary 独立于 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 V1(SQLite persistence schema V2);Glossary 独立于 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 V1(SQLite 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
|
||||
|
||||
+56
-17
@@ -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 hash(dry-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 V1,SQLite 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 内冲突会 blocked,priority、scope specificity、
|
||||
匹配长度和 term ID 使用确定性排序。允许但非推荐译法只产生 warning,系统不会在译文
|
||||
生成后自动替换文本。provider、TM、人工 task update、workbench 和 localized publish
|
||||
均执行相同 QA;blocking 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 内冲突会 blocked,priority、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 内冲突会 blocked,priority、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
@@ -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:
|
||||
|
||||
@@ -9,7 +9,3 @@ func InspectManifest(rawJSON string) (string, error) {
|
||||
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
|
||||
return ffi.BuildSyncPlan(currentJSON, previousJSON)
|
||||
}
|
||||
|
||||
func batVersion() (string, error) {
|
||||
return ffi.Version()
|
||||
}
|
||||
|
||||
+2
-2
@@ -21,6 +21,6 @@ func runSync(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, result)
|
||||
return nil
|
||||
_, err = fmt.Fprintln(os.Stdout, result)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 引擎版本号
|
||||
|
||||
@@ -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(¤t));
|
||||
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(¤t)
|
||||
|| 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(¤t, &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
@@ -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` 提供,运行中的服务也可
|
||||
|
||||
@@ -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 reuse,candidate 必须显式 confirm;
|
||||
lease/retry、结果落库、项目级 Translation Memory persistence schema V2 和独立 Glossary
|
||||
domain/feature contract V1(SQLite persistence schema V2)。TM 位于独立 SQLite,按 raw
|
||||
source + 完整 context 做 current Trusted exact reuse,candidate 必须显式 confirm;
|
||||
同一 identity 的不同译文必须显式 supersede,历史 Trusted 冲突必须显式 resolve;
|
||||
Glossary 只有 approved term 进入 provider/TM 自动流程,并在结果上执行确定性 QA;模糊
|
||||
匹配和完整 Provider 体系仍属后续缺口。
|
||||
|
||||
@@ -229,7 +231,7 @@ type TranslationProvider interface {
|
||||
- Azure Translator Provider
|
||||
|
||||
**翻译记忆库**:
|
||||
- 当前 V1:raw source 完全相同、完整 context 完全相同且记录为 trusted 时自动复用。
|
||||
- 当前规则:raw source 完全相同、完整 context 完全相同且只有一条 current Trusted 时自动复用。
|
||||
- provider 输出写入先是 candidate;manual 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 MVP;Rust `bat` 的 Glossary V1 已实现,登录、角色、Web 术语管理和完整协作审核仍未实现。
|
||||
当前只有 `bat-api` 内嵌 dashboard MVP;Rust `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 V1,Web 术语协作视图仍待建设**;
|
||||
Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。
|
||||
4. **完整游戏业务 API、完整 Web 协作后台和 Provider 扩展体系仍是后续目标;Rust `bat` 已持有
|
||||
Glossary domain/feature contract V1(SQLite persistence schema V2),Web 术语协作视图仍待建设**;
|
||||
Translation Memory persistence schema V2 已由 Rust `bat` 持有,不能从目标架构图推断 Go
|
||||
侧拥有第二份状态。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/JSONL;zip 场景保留 archive entry,TextUnit 明细包含 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,可用于字符串元素 patch,managed-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 和 metadata;metadata 包含 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 integrity,ZIP 外层文件和内层 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 的宣称范围。
|
||||
|
||||
@@ -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 symlink 和 release 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 symlink、release 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` 指向自身;不伪装
|
||||
|
||||
@@ -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 audit;localized 必须额外满足
|
||||
`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,也不负责自动 repair;staging 默认保留
|
||||
以避免删除未持久化任务。
|
||||
|
||||
### 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
@@ -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。
|
||||
|
||||
|
||||
@@ -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/Text,UnityFS 操作
|
||||
必须保留 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 持有。
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
@@ -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、相对路径、size 和 BLAKE3。
|
||||
- `<output>/current/official-cas-reuse-references.json`:当前 release 获取的 CAS 引用清单;每个复用项占一条记录,release 清理或孤儿 staging GC 时据此递减引用。
|
||||
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size、BLAKE3、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 error,watch 模式下错误 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`,但生产同步默认应保持开启。
|
||||
|
||||
|
||||
@@ -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 attempt;schema 由 `schema_migrations` 版本表管理。
|
||||
next attempt;当前 schema version 为 V2,schema 由 `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 audit;localized 还要求 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 为 trusted;worker 之后才可自动复用。 |
|
||||
| `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 和 provenance;conflict 结果不可自动复用。 |
|
||||
| `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "...", "supersede_record_id": "..." }` | 显式确认 candidate 为 trusted;已有不同 current Trusted 时必须显式 supersede,worker 之后才可自动复用。 |
|
||||
| `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;冲突或未使用推荐/允许译法的结果不会自动完成或发布。允许但非推荐译法
|
||||
产生 warning;blocking 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 查询、确定性诊断和审核/删除 mutation;Go 只转发,不持有 Glossary 状态 |
|
||||
| `LocalizedBackend` | `localized.status`、`localized.publish`、`localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 |
|
||||
| `ReleaseBackend` | `release.status`、`release.list`、`release.distribution`、`release.cleanup` | 鉴权后的双 release 查询、验证分发选择和 dry-run/execute cleanup;Go 不持有 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 或临时目录,但不能依赖开发机真实资源目录。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 当前实现缺口清单
|
||||
|
||||
- **更新时间**:2026-09-04
|
||||
- **更新时间**:2026-09-13
|
||||
- **文档角色**:只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态。
|
||||
- **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。
|
||||
- **Go 进度**:`GO_STATUS.md`
|
||||
@@ -10,40 +10,47 @@
|
||||
|
||||
## 1. 当前工程缺口
|
||||
|
||||
### G-005:AssetBundle 复杂解析仍未完成
|
||||
### G-005:AssetBundle 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
|
||||
provenance;UnityFS 目标身份至少包含 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/match、manifest 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-012:Translation 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/rollback,publish 只有最终
|
||||
`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 entry,source 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-012:Translation Memory persistence schema V2 已实现,扩展能力仍缺失
|
||||
|
||||
### G-013:Glossary V1 已实现,协作视图仍缺失
|
||||
Rust `bat` 已提供独立项目级 SQLite TM,当前 persistence schema version 为 V2;schema 打开遵守
|
||||
只读 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 QA;provider、TM、人工 task/workbench 结果都记录 QA,blocking deviation 必须显式提交 reviewer/reason/provenance。`translation.glossary.*` 已通过 `bat.sock` 暴露,Go 仅提供鉴权后的 typed forwarding。剩余缺口是完整 Web 术语协作视图和更丰富的导入/搜索能力。
|
||||
### G-013:Glossary domain/feature contract V1、persistence schema V2 已实现,协作视图仍缺失
|
||||
|
||||
Rust `bat` 已提供独立项目级 `glossary.sqlite`,当前 schema version 为 V2;V2 正式
|
||||
吸收历史上未升版本的 `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 QA;provider、TM、人工 task/workbench 结果都记录 QA,blocking
|
||||
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`,补充真实网络长期运行报告。
|
||||
|
||||
@@ -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/manifest;manifest 请求绑定 attested release/publication/mapping/manifest identity 和 verification generation |
|
||||
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
|
||||
| I | 生产中 Rust `bat` 与 `bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 |
|
||||
| J | 索引以 **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 helper;typed 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` 走同一 contract;fake 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 helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`release.attestation/status/list/distribution/cleanup`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm/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` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
|
||||
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + `/v1/releases` + `/v1/distribution` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + release/localized/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` |
|
||||
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
||||
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
||||
| 空骨架 | `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 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 |
|
||||
|
||||
---
|
||||
|
||||
+830
-115
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
|
||||
@@ -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-* 命令"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1274
-142
File diff suppressed because it is too large
Load Diff
+33
-18
@@ -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
@@ -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(¤t_update_snapshot));
|
||||
let _ = write_official_distribution_attestation_at(
|
||||
&active_resource_root,
|
||||
&active_resource_root,
|
||||
&release_id,
|
||||
"invalid",
|
||||
self.attestation_max_age_seconds,
|
||||
vec![format!("本地 manifest 审计失败:{error}")],
|
||||
);
|
||||
}
|
||||
return Err(anyhow::Error::msg(error));
|
||||
}
|
||||
}
|
||||
} else if config.audit_local {
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"audit",
|
||||
@@ -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(¤t_update_snapshot));
|
||||
let mut local_attestation_invalidated = false;
|
||||
if !config.dry_run
|
||||
&& has_current_pointer
|
||||
&& local_audit.as_ref().is_some_and(|audit| !audit.is_clean())
|
||||
{
|
||||
let diagnostics = local_audit
|
||||
.as_ref()
|
||||
.map(|audit| {
|
||||
vec![format!(
|
||||
"本地 manifest 审计失败:{} 项需要修复",
|
||||
audit.repair_needed_count()
|
||||
)]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
write_official_distribution_attestation_at(
|
||||
&active_resource_root,
|
||||
&active_resource_root,
|
||||
&active_release_id,
|
||||
"invalid",
|
||||
self.attestation_max_age_seconds,
|
||||
diagnostics,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
local_attestation_invalidated = true;
|
||||
}
|
||||
let localized_info = localized_release_info_for(config, Some(active_release_id.as_str()));
|
||||
let mut report = OfficialUpdateReport {
|
||||
update_status: if should_download {
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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, ¤t, &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
@@ -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, ¶ms) {
|
||||
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, ¶ms) {
|
||||
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
@@ -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
@@ -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))
|
||||
|
||||
@@ -332,7 +332,8 @@ BAT_API_STATE_DIR=/tmp/bat-pid
|
||||
# BAT_API_SOCKET=/tmp/bat-pid/bat.sock
|
||||
|
||||
# Optional override of the published release root (fixtures / emergency only).
|
||||
# Production obtains resource_root from daemon RPC (catalog.status / resource.manifest).
|
||||
# Production obtains resource_root from daemon RPC (release.attestation +
|
||||
# generation-bound resource.manifest).
|
||||
# BAT_API_RESOURCE_ROOT=
|
||||
|
||||
# Optional server-info JSON for Addressables root rewrite
|
||||
|
||||
@@ -24,12 +24,14 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
|
||||
availableRaw := readContractFixture(t, "catalog-status.available.json")
|
||||
unavailableRaw := readContractFixture(t, "catalog-status.unavailable.json")
|
||||
manifestRaw := readContractFixture(t, "resource-manifest.page0.json")
|
||||
attestationRaw := readContractFixture(t, "release-attestation.json")
|
||||
snapshotRaw := readContractFixture(t, "official-sync-snapshot.json")
|
||||
|
||||
for name, raw := range map[string][]byte{
|
||||
"catalog available": availableRaw,
|
||||
"catalog unavailable": unavailableRaw,
|
||||
"resource manifest": manifestRaw,
|
||||
"attestation": attestationRaw,
|
||||
"snapshot": snapshotRaw,
|
||||
} {
|
||||
if bytes.Contains(raw, []byte("/tmp/")) {
|
||||
@@ -71,7 +73,12 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
|
||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||
t.Fatalf("decode resource manifest: %v", err)
|
||||
}
|
||||
if !manifest.Available || manifest.ManifestVersion != 1 || manifest.TotalEntries != 2 {
|
||||
if !manifest.Available || manifest.Channel != "official" ||
|
||||
manifest.ManifestVersion != 1 || manifest.TotalEntries != 2 ||
|
||||
manifest.ReleaseID != "${VERSION_ID}" ||
|
||||
manifest.PublicationIdentity != "${PUBLICATION_IDENTITY}" ||
|
||||
manifest.ManifestIdentity != "${MANIFEST_IDENTITY}" ||
|
||||
manifest.Generation != 7 {
|
||||
t.Fatalf("manifest header=%+v", manifest)
|
||||
}
|
||||
if len(manifest.Entries) != 2 {
|
||||
@@ -84,6 +91,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"`
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 或完整发布切换验证。
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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, ¶ms); 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, ¶ms); err != nil {
|
||||
t.Fatalf("decode params: %v", err)
|
||||
}
|
||||
if params.ReleaseID != "official-a" ||
|
||||
params.ExpectedPublicationIdentity != "odp-v1-publication-a" ||
|
||||
params.ExpectedManifestIdentity != "manifest-a" ||
|
||||
params.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, ¶ms); 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, ¶ms); 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, ¶ms); 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,
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user