diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index da1e62b..e80990c 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # BlueArchiveToolkit 当前工作区状态 -- **更新时间**:2026-09-08 +- **更新时间**:2026-09-12 - **状态来源**:本地工作区盘点、代码验证和最新提交 - **状态分支**:`experiment` - **最新已推送功能提交**:以当前 `git log --oneline -1` 为准 @@ -30,7 +30,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `/.cas`、SQLite 索引为 `/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 任务历史持久化在 `/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 位于 `/translation-memory.sqlite`,不放在 `versions/` 或 release task 库中; `translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整 @@ -156,7 +156,7 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人 待完成: -- 基于已接入的 `translation.worker.run` 继续推进完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit/TM/Glossary 查询和通用 Patch 发布资源视图。 +- 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary、复杂 AssetBundle fixture 和 generic manifest 发布资源视图。 - 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`;实际运行报告由脚本写入隔离输出目录。 - 增加更多权限和极端文件系统场景测试。 @@ -187,14 +187,14 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人 ### `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` 和 `HashSet` 形态、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 重打包、更多双 release 查询/分发视图和清理策略;当前 generic manifest 只承诺已验证的 Binary/JSON/Text 与 UnityFS 结构,不等价于任意整体 AssetBundle 重打包。 - `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。 ### `bat-ffi` @@ -316,7 +316,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 运维视图未完成。 +- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、双 release 运维视图,以及真实官方资源长期运行验证。 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 5ab6146..88fc496 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -42,7 +42,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 ### 仍是骨架或占位 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、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService` 已完成受支持 TextUnit 到 localized patch manifest、独立 staging、发布和 rollback 闭环;ZIP 内 bundle 在 `archive_entry` 可验证时会重写外层 ZIP,通用 manifest 发布与任意 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 侧仅代理。 @@ -306,7 +306,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 3. 实现客户端发现、路径校验、备份、应用、回滚。 4. 实现 `patch build`、`patch apply`、`patch rollback`、`verify`。 5. 实现 dry-run 和安全检查。 -6. 将通用 Patch manifest 与汉化发布流程进一步统一。 +6. 在 generic Patch manifest 已统一当前支持类型后,继续补复杂 AssetBundle 兼容和双 release 运维视图。 验收标准: @@ -387,7 +387,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 ## 5. 推荐执行顺序 -近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证。 +近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、复杂 AssetBundle 重打包、双 release 运维视图和真实官方资源长期运行验证。 建议顺序: @@ -405,7 +405,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 +461,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。 -已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、通用 manifest 发布、真实版本与复杂 AssetBundle 兼容和官方资源长期运行报告。 +已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、generic manifest、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、真实版本与复杂 AssetBundle 兼容、双 release 运维视图和官方资源长期运行报告。 --- - **下一份应补充的验证材料**:真实官方网络 smoke 运行记录 -- **下一项工程任务**:推进 TM/Glossary 扩展、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。 +- **下一项工程任务**:推进 TM/Glossary 扩展、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。 diff --git a/README.md b/README.md index 950a0a4..b1946f1 100644 --- a/README.md +++ b/README.md @@ -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 重打包仍在后续阶段。 --- @@ -30,7 +30,7 @@ - `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 文本字段。 +- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前 generic manifest 已驱动已验证的 Binary/JSON/Text 与 UnityFS localized 操作,未知结构仍明确拒绝。 - Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory V1 与 Glossary V1 已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。 - SDK、完整 Web 协作后台。 diff --git a/USERGUIDE.md b/USERGUIDE.md index f803de5..1385d8a 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -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) | @@ -195,7 +195,7 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn | `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 暴露。 diff --git a/api/openapi/bat-api.yaml b/api/openapi/bat-api.yaml index 2de744d..dfbd701 100644 --- a/api/openapi/bat-api.yaml +++ b/api/openapi/bat-api.yaml @@ -723,6 +723,8 @@ paths: type: string from_worker: type: boolean + patch_manifest: + type: string localized_release_id: type: string responses: diff --git a/crates/bat-assetbundle/src/patch.rs b/crates/bat-assetbundle/src/patch.rs index 9c5148c..f96b26e 100644 --- a/crates/bat-assetbundle/src/patch.rs +++ b/crates/bat-assetbundle/src/patch.rs @@ -149,8 +149,18 @@ pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result, +} + +/// 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, } /// 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, + /// Optional size of the bytes immediately before this operation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_size: Option, + /// Optional archive entry for a UnityFS bundle nested in a ZIP. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_entry: Option, + /// 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, +} + +/// 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, + /// Replacement bytes. + replacement: Vec, + }, + /// 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, + /// 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, + /// 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::>(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, + /// BLAKE3 of the validated source text. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_text_blake3: Option, + /// Translation provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub translation_provider: Option, + /// Provider run identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_run_id: Option, + /// Translation source kind. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub translation_source_kind: Option, + /// Trusted Translation Memory record identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub translation_memory_record_id: Option, + /// Review status. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review_status: Option, + /// Deterministic Glossary QA report. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glossary_qa: Option, + /// Explicit Glossary QA override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glossary_override: Option, +} + +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 { - 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,275 @@ 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 mut targets = std::collections::BTreeSet::new(); + 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 let Some(target) = unity_operation_target(operation) { + if !targets.insert(target) { + return Err(PatchError::ApplyFailed(format!( + "patch manifest repeats an incompatible UnityFS target: {} 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 + ))); + } + } + } + Ok(()) +} + +fn unity_operation_target(operation: &PatchManifestOperation) -> Option<(Option<&str>, &str, i64)> { + let (serialized_file_path, path_id) = match &operation.payload { + PatchManifestOperationPayload::UnityFsTextAsset { + serialized_file_path, + path_id, + .. + } => (serialized_file_path, *path_id), + PatchManifestOperationPayload::UnityFsStringField { + serialized_file_path, + path_id, + .. + } + | PatchManifestOperationPayload::UnityFsField { + serialized_file_path, + path_id, + .. + } => (serialized_file_path, *path_id), + _ => return None, + }; + Some(( + operation.archive_entry.as_deref(), + serialized_file_path.as_str(), + path_id, + )) +} + +/// 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, + source_version: impl Into, + target_version: impl Into, + files: Vec, + rollback: PatchRollback, +) -> crate::Result { + 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() + ))); + } + if file.operations.len() > 1 && has_unity_operation { + return Err(PatchError::ApplyFailed(format!( + "manifest builder requires one UnityFS operation per file; use adapter-supplied manifest operations for multiple UnityFS targets: {}", + file.path.display() + ))); + } + let operation_count = file.operations.len(); + let mut current = source.clone(); + let operations = file + .operations + .into_iter() + .enumerate() + .map(|(sequence, mut operation)| { + operation.payload.validate()?; + let source_blake3 = (operation_count > 1).then(|| blake3_hex(¤t)); + let source_size = (operation_count > 1).then_some(current.len() as u64); + if operation.source_blake3.is_none() && operation_count > 1 { + if has_archive_operation || has_unity_operation { + return Err(PatchError::ApplyFailed(format!( + "manifest builder requires source preconditions for multiple non-direct operations: {}", + file.path.display() + ))); + } + operation.source_blake3 = source_blake3; + operation.source_size = source_size; + } + 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 + ))); + } + } + if !has_unity_operation && !has_archive_operation { + current = apply_direct_payload(¤t, &operation.payload)?; + } + operation.sequence = sequence as u32; + Ok(operation) + }) + .collect::>>()?; + if !has_unity_operation && !has_archive_operation && 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> { + 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 +614,14 @@ pub fn verify_patch_file_bytes( } fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result { - 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 +641,26 @@ fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result 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> { fs::read(path).map_err(|error| { PatchError::ApplyFailed(format!( @@ -238,6 +728,169 @@ 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_rejects_repeated_unity_object_target() { + let source = b"source"; + let target = b"target"; + let mut manifest = manifest_for("bundle", source, target); + manifest.files[0].patch_kind = PatchKind::Mixed; + manifest.files[0].operations = vec![ + PatchManifestOperation { + sequence: 0, + source_blake3: Some(blake3_hex(source)), + source_size: Some(source.len() as u64), + archive_entry: None, + payload: PatchManifestOperationPayload::UnityFsStringField { + serialized_file_path: "CAB-one".to_string(), + path_id: 7, + field_path: "first".to_string(), + expected_value: None, + replacement: "one".to_string(), + }, + provenance: None, + }, + PatchManifestOperation { + sequence: 1, + source_blake3: Some(blake3_hex(target)), + source_size: Some(target.len() as u64), + archive_entry: None, + payload: PatchManifestOperationPayload::UnityFsField { + serialized_file_path: "CAB-one".to_string(), + path_id: 7, + field_path: "second".to_string(), + expected_value: None, + replacement: serde_json::json!({"kind": "string", "value": "two"}), + }, + provenance: None, + }, + ]; + + assert!(validate_patch_manifest(&manifest).is_err()); + } + fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest { PatchManifest { version: PATCH_MANIFEST_VERSION, @@ -251,6 +904,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, diff --git a/docs/architecture/assetbundle.md b/docs/architecture/assetbundle.md index 8e15701..53e70e0 100644 --- a/docs/architecture/assetbundle.md +++ b/docs/architecture/assetbundle.md @@ -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。 - **开发状态**:解析扩展当前按路线图和真实回归继续推进。 @@ -32,7 +32,7 @@ | 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 的 UnityFS parse→modify→rebuild→reparse;可验证 ZIP 内 bundle 时会重写外层 ZIP;通用 manifest 发布仍未完成 | +| Patch 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | generic manifest 已驱动 Binary/JSON/Text 与当前支持的 UnityFS 操作;可验证 ZIP 内 bundle 时会在外层重写后重新读取、重解析并校验定位字段/替换值 | --- @@ -171,9 +171,9 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整 交付: -1. 已定义 `localized-patch-manifest.json`:目标官方版本、localized release、输出文件、hash、size、byte delta、TextUnit/provider/review trace 和回滚信息。 +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 字段级解析结果。 +3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果;generic manifest 不把 UnityFS 定位信息扁平化。 4. Patch 产物写入配置化汉化发布根下的 `.staging/`,校验通过后发布到 `versions/` 并切换 `current`;rollback 按 manifest 恢复上一 release。 5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release,ZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验。 @@ -181,7 +181,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整 1. Patch 失败不影响 `bat-resources/current`。 2. 汉化 release 保留官方相对目录结构。 -3. `localized` 状态能证明原版和汉化两套资源都已发布,且 patch manifest 可验证。 +3. `localized` 状态能证明原版和汉化两套资源都已发布,且 generic/localized patch manifest 可验证。 --- @@ -210,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 的宣称范围。 diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index dddd1f1..8148255 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -244,7 +244,7 @@ trusted 和 release/TextUnit/provider/run provenance;`translation.tasks` 优 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、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 及 ZIP 内层重解析校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。 +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`,表示原版和汉化两套资源都已发布。`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`,会在 `/official-launcher-bootstrap.pending.json` 写入待处理 launcher bootstrap 证据,供后续排障和自研客户端开发使用。 diff --git a/docs/architecture/resource-release-layout.md b/docs/architecture/resource-release-layout.md index 88d4d5a..ee6e393 100644 --- a/docs/architecture/resource-release-layout.md +++ b/docs/architecture/resource-release-layout.md @@ -1,6 +1,6 @@ # 官方资源 Release 布局与资源侧契约 -- **更新时间**:2026-09-04 +- **更新时间**:2026-09-12 - **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `bat-api` 分发 path 的 1:1 对应关系。 - **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。 - **权威代码**: @@ -75,7 +75,9 @@ / # 汉化产物发布根(--localized-output / BAT_LOCALIZED_OUTPUT) current -> versions/ # 已汉化后才切换;未汉化状态不发布 versions// # 与官方相对路径一致的汉化资源 - localized-version-state.json # 预留:后续 Patch 发布阶段维护,官方同步阶段不写入 + localized-patch-manifest.json # localized wrapper + generic PatchManifest 审计输入/结果 + .staging// # generic/translation patch 未发布写侧 + localized-version-state.json # localized current、官方 source release 和 workflow 状态 ``` 官方资源发布和汉化发布是两个独立状态: diff --git a/docs/guides/baseline.md b/docs/guides/baseline.md index 0ecfd0f..6f186de 100644 --- a/docs/guides/baseline.md +++ b/docs/guides/baseline.md @@ -89,8 +89,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。 diff --git a/docs/guides/bat-workflows.md b/docs/guides/bat-workflows.md index 6a31199..ebe4b1b 100644 --- a/docs/guides/bat-workflows.md +++ b/docs/guides/bat-workflows.md @@ -336,7 +336,8 @@ source text、destination 和 archive entry 后再落库。blocking Glossary QA ## localized patch 发布与回滚 `i18n publish` 会在独立的 `.staging/` 中复制当前官方 -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、发布时重新计算的 @@ -359,6 +360,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`: @@ -370,7 +386,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 持有。 ## 边界 diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index 0e8f29f..959f83e 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -337,7 +337,7 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t | 方法 | 状态 | 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` 或 @@ -353,12 +353,20 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t `current` symlink 指向汉化发布根下对应的 `versions/`,并且该版本目录中的 `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_integrity_status`、 +`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 | 方法 | 状态 | params | data | diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index d373af7..79900e3 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -1,6 +1,6 @@ # 当前实现缺口清单 -- **更新时间**:2026-09-10 +- **更新时间**:2026-09-12 - **文档角色**:只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态。 - **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。 - **Go 进度**:`GO_STATUS.md` @@ -32,21 +32,22 @@ directory 形态和未修改对象/字段,并明确拒绝未知压缩或无法 回归和 `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;`localized.publish`、`i18n publish` 和 bat-api typed forwarding 均已接入。 仍需完成: -- 通用 manifest 驱动的跨类型 patch build/apply/publish/rollback。 - 任意复杂 AssetBundle 重打包和完整翻译文件集合构建;当前 localized publish 已支持 可验证 ZIP 内 bundle 的外层 ZIP 重写,但不扩大 UnityFS 结构支持范围。 -- 原版 release 与 localized release 双发布后的查询、分发和清理策略。 +- 原版 release 与 localized release 双发布后的更丰富查询、分发和清理策略。 所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变 已发布的 `bat-resources/current` 或 `bat-localized/current`。 @@ -105,14 +106,14 @@ format 等资源级过滤,`parse.text_units` / `parse.errors` 和翻译任务 - 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。 - 更多 schema 迁移、权限、并发和损坏恢复场景验证。 -### G-011D:双 release 的完整查询与发布策略仍未完成 +### G-011D:双 release 的完整查询与运维策略仍未完成 -状态:**受支持范围完成,通用范围部分完成** +状态:**发布闭环完成,查询/清理扩展继续推进** 官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和 rollback,`localized.status` 能校验当前官方 release 与 patch manifest 的一致性。 -仍需完成通用 patch 发布、复杂重打包、双 release 查询/分发视图和清理策略。 +仍需完成复杂重打包、双 release 查询/分发视图和清理策略。 ### G-012:Translation Memory V1 已实现,扩展能力仍缺失 @@ -147,7 +148,7 @@ Rust `bat` 已提供独立项目级 `glossary.sqlite`:term/alias/recommended/a ## 3. 后续推进顺序 1. 继续 G-005:更多真实 AssetBundle 样本、复杂字段解析、版本差异和任意结构重打包。 -2. 继续 G-006/G-011D:通用 manifest Patch 和双 release 查询/清理策略。 +2. 继续 G-006/G-011D:复杂 AssetBundle 兼容和双 release 查询/清理策略。 3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider 扩展体系。 4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。 diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index 8af22a0..cf4dcaf 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -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` @@ -95,7 +95,7 @@ | 组件 | 路径 | 状态 | 说明 | |---|---|---|---| | Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 | -| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`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 输出字段 | +| 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 调用;`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` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` | diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index 325b3ad..d7eaab5 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -395,6 +395,7 @@ struct CliOptions { output_explicit: bool, resource_root: Option, translation_file: Option, + patch_manifest: Option, translation_id: Option, translation_text: Option, translation_text_file: Option, @@ -521,6 +522,7 @@ impl Default for CliOptions { output_explicit: false, resource_root: None, translation_file: None, + patch_manifest: None, translation_id: None, translation_text: None, translation_text_file: None, @@ -1130,6 +1132,8 @@ struct LocalizedPublishRpcParams { alias = "from_worker_results" )] from_worker: bool, + #[serde(default, alias = "patch_manifest_path")] + patch_manifest: Option, #[serde(default, alias = "release_id")] localized_release_id: Option, #[serde(default)] @@ -1226,8 +1230,8 @@ fn is_pending_rpc_method(method: &str) -> bool { // 等语义方法创建,通用创建接口暂不开放。 // daemon.clean-stable:CLI 侧按进程生命周期处理; // live RPC 内不做在线清理。 - // patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建、复杂 - // UnityFS 语义编辑和 inspect 等子命令仍未开放。 + // patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建和 inspect + // 等子命令仍未开放,发布级 generic manifest 通过 localized.publish 进入。 matches!(method, "task.create" | RPC_METHOD_CLEAN_STABLE) || (method.starts_with("patch.") && method != RPC_METHOD_PATCH_APPLY) || (method.starts_with("unityfs.") @@ -3277,8 +3281,13 @@ fn build_localized_status_report( let mut patch_manifest_path = None; let mut patch_manifest_available = false; let mut patch_manifest_matches_release = false; + let mut patch_manifest_integrity_status = None; + let mut patch_manifest_source_version = None; + let mut patch_manifest_target_version = None; let mut patch_file_count = None; let mut patch_text_asset_operation_count = None; + let mut patch_operation_count = None; + let mut patch_kind_counts = serde_json::Map::new(); let mut rollback_previous_current_target = None; let mut flow_status_code = if official_version_id.is_some() { ReleaseFlowStatusCode::LocalizedPending @@ -3326,6 +3335,47 @@ fn build_localized_status_report( patch_file_count = Some(manifest.file_count); patch_text_asset_operation_count = Some(manifest.text_asset_operation_count); rollback_previous_current_target = manifest.rollback.previous_current_target; + if let Some(generic_manifest) = manifest.patch_manifest.as_ref() { + patch_manifest_source_version = + Some(generic_manifest.source_version.clone()); + patch_manifest_target_version = + Some(generic_manifest.target_version.clone()); + let mut operation_count = 0usize; + for file in &generic_manifest.files { + for operation in &file.operations { + operation_count += 1; + let key = match operation.patch_kind() { + bat_patch::PatchKind::Binary => "binary", + bat_patch::PatchKind::Json => "json", + bat_patch::PatchKind::Text => "text", + bat_patch::PatchKind::UnityFsTextAsset => "unityfs_text_asset", + bat_patch::PatchKind::UnityFsStringField => { + "unityfs_string_field" + } + bat_patch::PatchKind::UnityFsField => "unityfs_field", + bat_patch::PatchKind::Mixed => "mixed", + } + .to_string(); + let count = patch_kind_counts + .entry(key) + .or_insert_with(|| serde_json::json!(0)); + *count = serde_json::json!(count.as_u64().unwrap_or_default() + 1); + } + } + patch_operation_count = Some(operation_count); + patch_manifest_integrity_status = Some( + if bat_patch::validate_patch_manifest(generic_manifest).is_ok() { + "valid" + } else { + "invalid" + }, + ); + patch_manifest_matches_release &= generic_manifest.source_version + == manifest.official_release_id + && generic_manifest.target_version == manifest.localized_release_id; + } else { + patch_manifest_integrity_status = Some("legacy"); + } } if candidate.is_dir() && current_points_to_published_version @@ -3363,8 +3413,13 @@ fn build_localized_status_report( "patch_manifest_path": patch_manifest_path, "patch_manifest_available": patch_manifest_available, "patch_manifest_matches_release": patch_manifest_matches_release, + "patch_manifest_integrity_status": patch_manifest_integrity_status, + "patch_manifest_source_version": patch_manifest_source_version, + "patch_manifest_target_version": patch_manifest_target_version, "patch_file_count": patch_file_count, "patch_text_asset_operation_count": patch_text_asset_operation_count, + "patch_operation_count": patch_operation_count, + "patch_kind_counts": patch_kind_counts, "rollback_previous_current_target": rollback_previous_current_target, "matches_current_official_release": matches_current_official_release, "current_points_to_published_version": current_points_to_published_version, @@ -3487,11 +3542,17 @@ fn localized_publish_rpc_report( let translation_file = params .translation_file .filter(|path| !path.as_os_str().is_empty()); - if translation_file.is_some() == params.from_worker { + let patch_manifest = params + .patch_manifest + .filter(|path| !path.as_os_str().is_empty()); + let input_count = usize::from(translation_file.is_some()) + + usize::from(params.from_worker) + + usize::from(patch_manifest.is_some()); + if input_count != 1 { return Err(ApiError::new( ErrorCode::RPC_INVALID_PARAMS, RPC_METHOD_LOCALIZED_PUBLISH, - "localized.publish 必须且只能指定 translation_file 或 from_worker", + "localized.publish 必须且只能指定 translation_file、from_worker 或 patch_manifest", )); } let mut config = base_config.clone(); @@ -3501,6 +3562,7 @@ fn localized_publish_rpc_report( config, state_dir: state_dir.to_path_buf(), translation_file, + patch_manifest, translation_from_worker: params.from_worker, localized_release_id: normalize_optional_rpc_string(params.localized_release_id), ..CliOptions::default() @@ -6844,6 +6906,9 @@ fn parse_args_with_env( options.translation_file = Some(PathBuf::from(next_option_value(&mut args, &flag)?)); } + "--patch-manifest" | "--patch-manifest-file" => { + options.patch_manifest = Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + } "--translation-id" => { options.translation_id = Some(next_option_value(&mut args, &flag)?); } @@ -7554,6 +7619,9 @@ fn parse_args_with_env( if options.command != CliCommand::PublishLocalized && options.translation_from_worker { return Err(anyhow::anyhow!("--from-worker 只适用于 i18n publish")); } + if options.command != CliCommand::PublishLocalized && options.patch_manifest.is_some() { + return Err(anyhow::anyhow!("--patch-manifest 只适用于 i18n publish")); + } match options.command { CliCommand::Status | CliCommand::Stop | CliCommand::Logs => { @@ -7805,10 +7873,13 @@ fn parse_args_with_env( )); } if matches!(options.command, CliCommand::PublishLocalized) - && options.translation_file.is_some() == options.translation_from_worker + && (usize::from(options.translation_file.is_some()) + + usize::from(options.translation_from_worker) + + usize::from(options.patch_manifest.is_some()) + != 1) { return Err(anyhow::anyhow!( - "i18n publish 必须且只能指定 --translation-file 或 --from-worker" + "i18n publish 必须且只能指定 --translation-file、--from-worker 或 --patch-manifest" )); } } diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index fef7160..4a2ea66 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -659,6 +659,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", @@ -5107,6 +5120,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() { 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(), @@ -5243,6 +5257,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(), @@ -5261,6 +5276,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(), @@ -5362,6 +5378,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(), diff --git a/infrastructure/src/bin/bat/patch_commands.rs b/infrastructure/src/bin/bat/patch_commands.rs index dddd449..4a9e119 100644 --- a/infrastructure/src/bin/bat/patch_commands.rs +++ b/infrastructure/src/bin/bat/patch_commands.rs @@ -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-* 命令" diff --git a/infrastructure/src/bin/bat/workflow_commands.rs b/infrastructure/src/bin/bat/workflow_commands.rs index 4eb8ac6..24f07d5 100644 --- a/infrastructure/src/bin/bat/workflow_commands.rs +++ b/infrastructure/src/bin/bat/workflow_commands.rs @@ -339,6 +339,49 @@ pub(super) fn publish_localized_report( options: &CliOptions, ) -> anyhow::Result { 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() diff --git a/infrastructure/src/localized_patch.rs b/infrastructure/src/localized_patch.rs index 406bc9f..56b8db1 100644 --- a/infrastructure/src/localized_patch.rs +++ b/infrastructure/src/localized_patch.rs @@ -2,7 +2,8 @@ use bat_assetbundle::{ patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch, Parser, - StringFieldPatch, TextAssetPatch, UnitySerializedField, UnitySerializedValue, + StringFieldPatch, TextAssetPatch, UnitySerializedField, UnitySerializedReplacementValue, + UnitySerializedValue, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -135,6 +136,8 @@ pub struct LocalizedPatchConfig { pub patches: Vec, /// General UnityFS text/field operations to apply. pub operations: Vec, + /// Unified manifest-driven publication input. + pub manifest: Option, /// Unzip executable used for ZIP-inner bundle publication. pub unzip_command: PathBuf, /// Zip executable used for ZIP-inner bundle publication. @@ -157,6 +160,7 @@ impl LocalizedPatchConfig { force: false, patches, operations: Vec::new(), + manifest: None, unzip_command: PathBuf::from("unzip"), zip_command: PathBuf::from("zip"), } @@ -180,6 +184,12 @@ impl LocalizedPatchConfig { self } + /// Sets a unified manifest-driven publication input. + pub fn with_manifest(mut self, manifest: bat_patch::PatchManifest) -> Self { + self.manifest = Some(manifest); + self + } + /// Sets the archive tools used for ZIP-inner bundle publication. pub fn with_archive_commands( mut self, @@ -344,6 +354,9 @@ pub struct LocalizedPatchFile { /// TextAsset operations applied to this file. #[serde(default)] pub text_asset_operations: Vec, + /// All operations applied to this file, including Binary/JSON/Text. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub operations: Vec, } /// One TextAsset patch operation recorded in the localized patch manifest. @@ -355,6 +368,12 @@ pub struct LocalizedPatchOperation { /// Patch operation kind, for example `unityfs_text_asset`. #[serde(default = "default_patch_operation_kind")] pub patch_kind: String, + /// BLAKE3 of the bytes immediately before this operation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_blake3: Option, + /// Size of the bytes immediately before this operation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_bytes: Option, /// UnityFS directory path of the serialized file. pub serialized_file_path: String, /// Unity object path ID. @@ -369,6 +388,15 @@ pub struct LocalizedPatchOperation { pub replacement_bytes: u64, /// BLAKE3 of the replacement payload. pub replacement_blake3: String, + /// Replacement payload retained for generic manifest conversion. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replacement: Option>, + /// Expected source semantic/string value. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_value: Option, + /// Expected semantic value for a TypeTree field replacement. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replacement_value: Option, /// Stable TextUnit ID from the official TextUnit index. #[serde(default, skip_serializing_if = "Option::is_none")] pub text_unit_id: Option, @@ -436,35 +464,74 @@ pub struct LocalizedPatchManifest { pub text_asset_operation_count: usize, /// Changed files and their before/after hashes. pub files: Vec, + /// Generic manifest used as the auditable publication input/output when + /// this release was driven by the unified workflow. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub patch_manifest: Option, /// Rollback information for this release. pub rollback: LocalizedPatchRollbackInfo, } impl LocalizedPatchManifest { - /// Converts the localized TextAsset manifest to the generic patch manifest model. - pub fn to_patch_manifest(&self) -> bat_patch::PatchManifest { - bat_patch::PatchManifest { + /// Converts this localized manifest to the generic patch manifest model. + pub fn to_patch_manifest(&self) -> anyhow::Result { + if let Some(manifest) = &self.patch_manifest { + return Ok(manifest.clone()); + } + let mut files = Vec::with_capacity(self.files.len()); + for file in &self.files { + let source_operations = if file.operations.is_empty() { + file.text_asset_operations + .iter() + .map(|operation| generic_manifest_operation(operation, 0)) + .collect::>>()? + } else { + file.operations.clone() + }; + let operations = source_operations + .iter() + .enumerate() + .map(|(sequence, operation)| { + let mut operation = operation.clone(); + operation.sequence = sequence as u32; + operation + }) + .collect::>(); + let patch_kind = operations + .iter() + .map(bat_patch::PatchManifestOperation::patch_kind) + .collect::>(); + let patch_kind = match patch_kind.len() { + 0 => { + return Err(anyhow::anyhow!( + "localized manifest file 没有可转换的 operation:{}", + file.path + )) + } + 1 => *patch_kind.first().expect("non-empty"), + _ => bat_patch::PatchKind::Mixed, + }; + files.push(bat_patch::PatchManifestFile { + path: PathBuf::from(&file.path), + patch_kind, + source_blake3: file.original_blake3.clone(), + target_blake3: file.localized_blake3.clone(), + source_size: file.original_bytes, + target_size: file.localized_bytes, + operations, + }); + } + Ok(bat_patch::PatchManifest { version: bat_patch::PATCH_MANIFEST_VERSION, patch_id: self.localized_release_id.clone(), source_version: self.official_release_id.clone(), target_version: self.localized_release_id.clone(), - files: self - .files - .iter() - .map(|file| bat_patch::PatchManifestFile { - path: PathBuf::from(&file.path), - patch_kind: bat_patch::PatchKind::UnityFsTextAsset, - source_blake3: file.original_blake3.clone(), - target_blake3: file.localized_blake3.clone(), - source_size: file.original_bytes, - target_size: file.localized_bytes, - }) - .collect(), + files, rollback: bat_patch::PatchRollback { previous_current_target: self.rollback.previous_current_target.clone(), remove_target_path: Some(self.rollback.remove_version_path.clone()), }, - } + }) } } @@ -779,69 +846,118 @@ impl LocalizedPatchService { fs::create_dir_all(&staging)?; copy_tree(&config.official_release_root, &staging)?; - let operations = config.patch_operations(); - let mut grouped = BTreeMap::>::new(); - for operation in operations { - grouped - .entry(operation.bundle_path().to_string()) - .or_default() - .push(operation); - } - - let mut changed_files = Vec::with_capacity(grouped.len()); - for (bundle_path, operations) in grouped { - let target = staging.join(Path::new(&bundle_path)); - ensure_path_within_root(&staging, &target).map_err(anyhow::Error::msg)?; - ensure_safe_file_target(&staging, &target, "汉化 patch 输入") - .map_err(anyhow::Error::msg)?; - let original = fs::read(&target)?; - let (patched, manifest_operations) = if operations - .iter() - .any(|operation| operation.archive_entry().is_some()) - { - if operations - .iter() - .any(|operation| operation.archive_entry().is_none()) - { - return Err(anyhow::anyhow!( - "{bundle_path}: 不能混合直接 bundle patch 与 ZIP 内 bundle patch" - )); - } - rewrite_zip_bundle( - &original, - &operations, - &config.unzip_command, - &config.zip_command, - )? - } else { - let mut patched = original.clone(); - let mut manifest_operations = Vec::with_capacity(operations.len()); - for operation in operations { - patched = operation - .apply(&patched) - .map_err(|error| anyhow::anyhow!("{bundle_path}: {error}"))?; - manifest_operations.push(operation.manifest_operation()?); - } - (patched, manifest_operations) - }; - if original == patched { - return Err(anyhow::anyhow!("patch produced no change: {bundle_path}")); + let (changed_files, generic_manifest) = if let Some(manifest) = &config.manifest { + if manifest.source_version != config.release_id { + return Err(anyhow::anyhow!( + "generic patch source version={} 与配置官方 release={} 不一致", + manifest.source_version, + config.release_id + )); + } + if manifest.target_version != config.published_release_id() { + return Err(anyhow::anyhow!( + "generic patch target version={} 与配置 localized release={} 不一致", + manifest.target_version, + config.published_release_id() + )); + } + let changed_files = apply_generic_manifest_to_staging( + &config.official_release_root, + &staging, + manifest, + &config.unzip_command, + &config.zip_command, + )?; + (changed_files, Some(manifest.clone())) + } else { + let operations = config.patch_operations(); + let mut grouped = BTreeMap::>::new(); + for operation in operations { + grouped + .entry(operation.bundle_path().to_string()) + .or_default() + .push(operation); } - write_file_atomic(&target, &patched, STATE_FILE_MODE, "汉化 patch 输出") - .map_err(anyhow::Error::msg)?; - let original_blake3 = blake3::hash(&original).to_hex().to_string(); - let localized_blake3 = blake3::hash(&patched).to_hex().to_string(); - changed_files.push(LocalizedPatchFile { - path: bundle_path, - original_blake3, - localized_blake3, - original_bytes: original.len() as u64, - localized_bytes: patched.len() as u64, - byte_delta: patched.len() as i64 - original.len() as i64, - text_asset_operations: manifest_operations, - }); - } + let mut changed_files = Vec::with_capacity(grouped.len()); + for (bundle_path, operations) in grouped { + let target = staging.join(Path::new(&bundle_path)); + ensure_path_within_root(&staging, &target).map_err(anyhow::Error::msg)?; + ensure_safe_file_target(&staging, &target, "汉化 patch 输入") + .map_err(anyhow::Error::msg)?; + let original = fs::read(&target)?; + let (patched, manifest_operations) = if operations + .iter() + .any(|operation| operation.archive_entry().is_some()) + { + if operations + .iter() + .any(|operation| operation.archive_entry().is_none()) + { + return Err(anyhow::anyhow!( + "{bundle_path}: 不能混合直接 bundle patch 与 ZIP 内 bundle patch" + )); + } + rewrite_zip_bundle( + &original, + &operations, + &config.unzip_command, + &config.zip_command, + None, + )? + } else { + let mut patched = original.clone(); + let mut manifest_operations = Vec::with_capacity(operations.len()); + for operation in operations { + let mut manifest_operation = operation.manifest_operation()?; + manifest_operation.source_blake3 = + Some(blake3::hash(&patched).to_hex().to_string()); + manifest_operation.source_bytes = Some(patched.len() as u64); + patched = operation + .apply(&patched) + .map_err(|error| anyhow::anyhow!("{bundle_path}: {error}"))?; + manifest_operations.push(manifest_operation); + } + (patched, manifest_operations) + }; + if original == patched { + return Err(anyhow::anyhow!("patch produced no change: {bundle_path}")); + } + write_file_atomic(&target, &patched, STATE_FILE_MODE, "汉化 patch 输出") + .map_err(anyhow::Error::msg)?; + let original_blake3 = blake3::hash(&original).to_hex().to_string(); + let localized_blake3 = blake3::hash(&patched).to_hex().to_string(); + let generic_operations = manifest_operations + .iter() + .enumerate() + .map(|(sequence, operation)| { + generic_manifest_operation(operation, sequence as u32) + }) + .collect::>>()?; + changed_files.push(LocalizedPatchFile { + path: bundle_path, + original_blake3, + localized_blake3, + original_bytes: original.len() as u64, + localized_bytes: patched.len() as u64, + byte_delta: patched.len() as i64 - original.len() as i64, + text_asset_operations: manifest_operations, + operations: generic_operations, + }); + } + (changed_files, None) + }; + + let generic_manifest = match generic_manifest { + Some(manifest) => manifest, + None => generic_manifest_from_localized_files( + config.release_id.as_str(), + config.published_release_id(), + &changed_files, + previous_current_target.clone(), + version_path.clone(), + )?, + }; let manifest = LocalizedPatchManifest { manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION, official_release_id: config.release_id.clone(), @@ -853,6 +969,13 @@ impl LocalizedPatchService { .map(|file| file.text_asset_operations.len()) .sum(), files: changed_files.clone(), + patch_manifest: Some(generic_manifest).map(|mut manifest| { + manifest.rollback = bat_patch::PatchRollback { + previous_current_target: previous_current_target.clone(), + remove_target_path: Some(version_path.clone()), + }; + manifest + }), rollback: LocalizedPatchRollbackInfo { previous_current_target: previous_current_target.clone(), remove_version_path: version_path.clone(), @@ -913,6 +1036,511 @@ impl LocalizedPatchService { } } +fn generic_manifest_from_localized_files( + source_version: &str, + target_version: &str, + files: &[LocalizedPatchFile], + previous_current_target: Option, + remove_target_path: PathBuf, +) -> anyhow::Result { + let files = files + .iter() + .map(|file| { + if file.operations.is_empty() { + return Err(anyhow::anyhow!( + "localized file 没有可审计 generic operation:{}", + file.path + )); + } + let kinds = file + .operations + .iter() + .map(bat_patch::PatchManifestOperation::patch_kind) + .collect::>(); + let patch_kind = if kinds.len() == 1 { + *kinds.first().expect("non-empty") + } else { + bat_patch::PatchKind::Mixed + }; + Ok(bat_patch::PatchManifestFile { + path: PathBuf::from(&file.path), + patch_kind, + source_blake3: file.original_blake3.clone(), + target_blake3: file.localized_blake3.clone(), + source_size: file.original_bytes, + target_size: file.localized_bytes, + operations: file.operations.clone(), + }) + }) + .collect::>>()?; + let manifest = bat_patch::PatchManifest { + version: bat_patch::PATCH_MANIFEST_VERSION, + patch_id: target_version.to_string(), + source_version: source_version.to_string(), + target_version: target_version.to_string(), + files, + rollback: bat_patch::PatchRollback { + previous_current_target, + remove_target_path: Some(remove_target_path), + }, + }; + bat_patch::validate_patch_manifest(&manifest) + .map_err(|error| anyhow::anyhow!("localized generic manifest 无效:{error}"))?; + Ok(manifest) +} + +fn apply_generic_manifest_to_staging( + official_release_root: &Path, + staging_root: &Path, + manifest: &bat_patch::PatchManifest, + unzip_command: &Path, + zip_command: &Path, +) -> anyhow::Result> { + bat_patch::validate_patch_manifest(manifest) + .map_err(|error| anyhow::anyhow!("generic patch manifest 无效:{error}"))?; + if manifest.files.is_empty() { + return Err(anyhow::anyhow!("generic patch manifest 不能没有文件")); + } + + let mut changed_files = Vec::with_capacity(manifest.files.len()); + for file in &manifest.files { + if file.operations.is_empty() { + return Err(anyhow::anyhow!( + "generic patch manifest 文件没有操作:{}", + file.path.display() + )); + } + if file.patch_kind != bat_patch::PatchKind::Mixed + && file + .operations + .iter() + .any(|operation| operation.patch_kind() != file.patch_kind) + { + return Err(anyhow::anyhow!( + "generic patch manifest file kind 与 operation 不一致:{}", + file.path.display() + )); + } + let source_path = official_release_root.join(&file.path); + let target_path = staging_root.join(&file.path); + ensure_path_within_root(official_release_root, &source_path).map_err(anyhow::Error::msg)?; + ensure_path_within_root(staging_root, &target_path).map_err(anyhow::Error::msg)?; + ensure_safe_file_target(official_release_root, &source_path, "generic patch 原文件") + .map_err(anyhow::Error::msg)?; + ensure_safe_file_target(staging_root, &target_path, "generic patch staging 文件") + .map_err(anyhow::Error::msg)?; + let original = fs::read(&source_path)?; + let source_hash = blake3::hash(&original).to_hex().to_string(); + if source_hash != file.source_blake3 || original.len() as u64 != file.source_size { + return Err(anyhow::anyhow!( + "generic patch source identity mismatch {}: expected hash={} bytes={}, actual hash={} bytes={}", + file.path.display(), + file.source_blake3, + file.source_size, + source_hash, + original.len() + )); + } + + let has_archive = file + .operations + .iter() + .any(|operation| operation.archive_entry.is_some()); + let has_direct = file + .operations + .iter() + .any(|operation| operation.archive_entry.is_none()); + if has_archive && has_direct { + return Err(anyhow::anyhow!( + "generic patch manifest 不能混合直接文件操作和 ZIP 内操作:{}", + file.path.display() + )); + } + let (patched, localized_operations) = if has_archive { + let inputs = file + .operations + .iter() + .map(generic_operation_to_localized_input) + .collect::>>()?; + let (patched, _) = rewrite_zip_bundle( + &original, + &inputs, + unzip_command, + zip_command, + Some(&file.operations), + )?; + let localized_operations = file + .operations + .iter() + .filter_map(|operation| { + generic_operation_to_localized_operation(operation).transpose() + }) + .collect::>>()?; + (patched, localized_operations) + } else { + let mut patched = original.clone(); + for operation in &file.operations { + patched = apply_generic_operation(&patched, operation)?; + } + let localized_operations = file + .operations + .iter() + .filter_map(|operation| { + generic_operation_to_localized_operation(operation).transpose() + }) + .collect::>>()?; + (patched, localized_operations) + }; + if original == patched { + return Err(anyhow::anyhow!( + "generic patch manifest operation produced no change: {}", + file.path.display() + )); + } + let target_hash = blake3::hash(&patched).to_hex().to_string(); + if target_hash != file.target_blake3 || patched.len() as u64 != file.target_size { + return Err(anyhow::anyhow!( + "generic patch target mismatch {}: expected hash={} bytes={}, actual hash={} bytes={}", + file.path.display(), + file.target_blake3, + file.target_size, + target_hash, + patched.len() + )); + } + write_file_atomic( + &target_path, + &patched, + STATE_FILE_MODE, + "generic patch staging 输出", + ) + .map_err(anyhow::Error::msg)?; + changed_files.push(LocalizedPatchFile { + path: file.path.to_string_lossy().into_owned(), + original_blake3: source_hash, + localized_blake3: target_hash, + original_bytes: original.len() as u64, + localized_bytes: patched.len() as u64, + byte_delta: patched.len() as i64 - original.len() as i64, + text_asset_operations: localized_operations, + operations: file.operations.clone(), + }); + } + + verify_generic_manifest_outputs(official_release_root, staging_root, manifest, unzip_command)?; + Ok(changed_files) +} + +fn apply_generic_operation( + input: &[u8], + operation: &bat_patch::PatchManifestOperation, +) -> anyhow::Result> { + verify_generic_operation_source(input, operation)?; + match &operation.payload { + bat_patch::PatchManifestOperationPayload::Binary { patch } => { + Ok(bat_patch::binary::apply_binary_patch(input, patch)?) + } + bat_patch::PatchManifestOperationPayload::Json { patch } => { + let source = std::str::from_utf8(input) + .map_err(|error| anyhow::anyhow!("JSON patch source 不是 UTF-8:{error}"))?; + let patch = serde_json::to_string(patch)?; + Ok(bat_patch::json::apply_json_patch(source, &patch)?.into_bytes()) + } + bat_patch::PatchManifestOperationPayload::Text { patch } => { + let source = std::str::from_utf8(input) + .map_err(|error| anyhow::anyhow!("Text patch source 不是 UTF-8:{error}"))?; + Ok(bat_patch::text::apply_text_patch(source, patch)?.into_bytes()) + } + bat_patch::PatchManifestOperationPayload::UnityFsTextAsset { .. } + | bat_patch::PatchManifestOperationPayload::UnityFsStringField { .. } + | bat_patch::PatchManifestOperationPayload::UnityFsField { .. } => { + Ok(generic_operation_to_localized_input(operation)?.apply(input)?) + } + } +} + +fn verify_generic_operation_source( + input: &[u8], + operation: &bat_patch::PatchManifestOperation, +) -> anyhow::Result<()> { + let Some(expected_hash) = operation.source_blake3.as_deref() else { + if operation.source_size.is_some() { + return Err(anyhow::anyhow!( + "generic patch operation source precondition 缺少 hash" + )); + } + return Ok(()); + }; + let expected_size = operation + .source_size + .ok_or_else(|| anyhow::anyhow!("generic patch operation source precondition 缺少 size"))?; + let actual_hash = blake3::hash(input).to_hex().to_string(); + if actual_hash != expected_hash || input.len() as u64 != expected_size { + return Err(anyhow::anyhow!( + "generic patch operation source precondition mismatch at sequence {}: expected hash={} bytes={}, actual hash={} bytes={}", + operation.sequence, + expected_hash, + expected_size, + actual_hash, + input.len() + )); + } + Ok(()) +} + +fn generic_operation_to_localized_input( + operation: &bat_patch::PatchManifestOperation, +) -> anyhow::Result { + let archive_entry = operation.archive_entry.clone(); + let input = match &operation.payload { + bat_patch::PatchManifestOperationPayload::UnityFsTextAsset { + serialized_file_path, + path_id, + expected_name, + replacement, + } => LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch { + bundle_path: String::new(), + archive_entry, + text_asset: TextAssetPatch { + serialized_file_path: serialized_file_path.clone(), + path_id: *path_id, + expected_name: expected_name.clone(), + replacement: replacement.clone(), + }, + metadata: None, + }), + bat_patch::PatchManifestOperationPayload::UnityFsStringField { + serialized_file_path, + path_id, + field_path, + expected_value, + replacement, + } => LocalizedPatchInput::StringField(LocalizedStringFieldPatch { + bundle_path: String::new(), + archive_entry, + string_field: StringFieldPatch { + serialized_file_path: serialized_file_path.clone(), + path_id: *path_id, + field_path: field_path.clone(), + expected_value: expected_value.clone(), + replacement: replacement.clone(), + }, + metadata: None, + }), + bat_patch::PatchManifestOperationPayload::UnityFsField { + serialized_file_path, + path_id, + field_path, + expected_value, + replacement, + } => LocalizedPatchInput::Field(LocalizedFieldPatch { + bundle_path: String::new(), + archive_entry, + field: FieldPatch { + serialized_file_path: serialized_file_path.clone(), + path_id: *path_id, + field_path: field_path.clone(), + expected_value: expected_value + .as_ref() + .map(|value| serde_json::from_value(value.clone())) + .transpose()?, + replacement: serde_json::from_value(replacement.clone())?, + }, + metadata: None, + }), + _ => { + return Err(anyhow::anyhow!( + "generic manifest operation kind 不能作为 UnityFS 操作" + )) + } + }; + Ok(input) +} + +fn generic_operation_to_localized_operation( + operation: &bat_patch::PatchManifestOperation, +) -> anyhow::Result> { + let mut localized = match &operation.payload { + bat_patch::PatchManifestOperationPayload::UnityFsTextAsset { + serialized_file_path, + path_id, + expected_name, + replacement, + } => LocalizedPatchOperation { + archive_entry: operation.archive_entry.clone(), + patch_kind: "unityfs_text_asset".to_string(), + source_blake3: operation.source_blake3.clone(), + source_bytes: operation.source_size, + serialized_file_path: serialized_file_path.clone(), + path_id: *path_id, + field_path: None, + expected_name: expected_name.clone(), + replacement_bytes: replacement.len() as u64, + replacement_blake3: blake3::hash(replacement).to_hex().to_string(), + replacement: Some(replacement.clone()), + expected_value: None, + replacement_value: None, + ..LocalizedPatchOperation::default() + }, + bat_patch::PatchManifestOperationPayload::UnityFsStringField { + serialized_file_path, + path_id, + field_path, + expected_value, + replacement, + } => LocalizedPatchOperation { + archive_entry: operation.archive_entry.clone(), + patch_kind: "unityfs_string_field".to_string(), + source_blake3: operation.source_blake3.clone(), + source_bytes: operation.source_size, + serialized_file_path: serialized_file_path.clone(), + path_id: *path_id, + field_path: Some(field_path.clone()), + replacement_bytes: replacement.len() as u64, + replacement_blake3: blake3::hash(replacement.as_bytes()).to_hex().to_string(), + replacement: Some(replacement.as_bytes().to_vec()), + expected_value: expected_value + .as_ref() + .map(|value| UnitySerializedReplacementValue::String(value.clone())), + replacement_value: None, + ..LocalizedPatchOperation::default() + }, + bat_patch::PatchManifestOperationPayload::UnityFsField { + serialized_file_path, + path_id, + field_path, + expected_value, + replacement, + } => { + let replacement_value: UnitySerializedReplacementValue = + serde_json::from_value(replacement.clone())?; + let replacement_bytes = serde_json::to_vec(&replacement_value)?; + let expected_value = expected_value + .as_ref() + .map(|value| serde_json::from_value(value.clone())) + .transpose()?; + LocalizedPatchOperation { + archive_entry: operation.archive_entry.clone(), + patch_kind: "unityfs_field".to_string(), + source_blake3: operation.source_blake3.clone(), + source_bytes: operation.source_size, + serialized_file_path: serialized_file_path.clone(), + path_id: *path_id, + field_path: Some(field_path.clone()), + replacement_bytes: replacement_bytes.len() as u64, + replacement_blake3: blake3::hash(&replacement_bytes).to_hex().to_string(), + replacement: None, + expected_value, + replacement_value: Some(replacement_value), + ..LocalizedPatchOperation::default() + } + } + _ => return Ok(None), + }; + apply_generic_provenance(&mut localized, operation.provenance.as_ref())?; + Ok(Some(localized)) +} + +fn apply_generic_provenance( + operation: &mut LocalizedPatchOperation, + provenance: Option<&bat_patch::PatchManifestProvenance>, +) -> anyhow::Result<()> { + let Some(provenance) = provenance else { + return Ok(()); + }; + operation.text_unit_id = provenance.text_unit_id.clone(); + operation.source_text_blake3 = provenance.source_text_blake3.clone(); + operation.translation_provider = provenance.translation_provider.clone(); + operation.provider_run_id = provenance.provider_run_id.clone(); + operation.translation_source_kind = provenance.translation_source_kind.clone(); + operation.translation_memory_record_id = provenance.translation_memory_record_id.clone(); + operation.review_status = provenance.review_status.clone(); + operation.glossary_qa = provenance + .glossary_qa + .as_ref() + .map(|value| serde_json::from_value(value.clone())) + .transpose()?; + operation.glossary_override = provenance + .glossary_override + .as_ref() + .map(|value| serde_json::from_value(value.clone())) + .transpose()?; + Ok(()) +} + +fn verify_generic_manifest_outputs( + official_release_root: &Path, + localized_release_root: &Path, + manifest: &bat_patch::PatchManifest, + unzip_command: &Path, +) -> anyhow::Result<()> { + for file in &manifest.files { + let official_path = official_release_root.join(&file.path); + let localized_path = localized_release_root.join(&file.path); + let original = fs::read(&official_path)?; + let localized = fs::read(&localized_path)?; + let file_metadata = bat_patch::PatchManifestFile { + path: file.path.clone(), + patch_kind: file.patch_kind, + source_blake3: file.source_blake3.clone(), + target_blake3: file.target_blake3.clone(), + source_size: file.source_size, + target_size: file.target_size, + operations: Vec::new(), + }; + bat_patch::verify_patch_file_bytes(&original, &localized, &file_metadata)?; + let has_unity_operation = file.operations.iter().any(|operation| { + matches!( + operation.payload, + bat_patch::PatchManifestOperationPayload::UnityFsTextAsset { .. } + | bat_patch::PatchManifestOperationPayload::UnityFsStringField { .. } + | bat_patch::PatchManifestOperationPayload::UnityFsField { .. } + ) + }); + if file + .operations + .iter() + .all(|operation| operation.archive_entry.is_none()) + && !has_unity_operation + { + let mut replayed = original.clone(); + for operation in &file.operations { + replayed = apply_generic_operation(&replayed, operation)?; + } + if replayed != localized { + return Err(anyhow::anyhow!( + "generic patch final replay 与 published bytes 不一致:{}", + file.path.display() + )); + } + } + let operations = file + .operations + .iter() + .filter_map(|operation| generic_operation_to_localized_operation(operation).transpose()) + .collect::>>()?; + let operation_refs = operations.iter().collect::>(); + if file + .operations + .iter() + .any(|operation| operation.archive_entry.is_some()) + { + verify_zip_operations(unzip_command, &official_path, &operation_refs, false)?; + verify_zip_operations(unzip_command, &localized_path, &operation_refs, true)?; + } else if !operation_refs.is_empty() { + let parsed = bat_assetbundle::UnityFsParser::new().parse(&localized)?; + for operation in &operation_refs { + verify_archive_operation_replacement( + &parsed, + operation, + file.path.to_string_lossy().as_ref(), + )?; + } + } + } + Ok(()) +} + /// Reads the localized release state without following a symlink at the file /// path. A missing state file means no localized release has been published. pub fn read_localized_version_state( @@ -1027,12 +1655,17 @@ impl LocalizedPatchOperation { Self { archive_entry: None, patch_kind: "unityfs_text_asset".to_string(), + source_blake3: None, + source_bytes: None, serialized_file_path: patch.serialized_file_path.clone(), path_id: patch.path_id, field_path: None, expected_name: patch.expected_name.clone(), replacement_bytes: patch.replacement.len() as u64, replacement_blake3: blake3::hash(&patch.replacement).to_hex().to_string(), + replacement: Some(patch.replacement.clone()), + expected_value: None, + replacement_value: None, text_unit_id: None, source_text_blake3: None, translation_provider: None, @@ -1055,6 +1688,8 @@ impl LocalizedPatchOperation { Self { archive_entry: None, patch_kind: "unityfs_string_field".to_string(), + source_blake3: None, + source_bytes: None, serialized_file_path: patch.serialized_file_path.clone(), path_id: patch.path_id, field_path: Some(patch.field_path.clone()), @@ -1063,6 +1698,12 @@ impl LocalizedPatchOperation { replacement_blake3: blake3::hash(patch.replacement.as_bytes()) .to_hex() .to_string(), + replacement: Some(patch.replacement.as_bytes().to_vec()), + expected_value: patch + .expected_value + .as_ref() + .map(|value| UnitySerializedReplacementValue::String(value.clone())), + replacement_value: None, text_unit_id: None, source_text_blake3: None, translation_provider: None, @@ -1086,12 +1727,17 @@ impl LocalizedPatchOperation { Self { archive_entry: None, patch_kind: "unityfs_field".to_string(), + source_blake3: None, + source_bytes: None, serialized_file_path: patch.serialized_file_path.clone(), path_id: patch.path_id, field_path: Some(patch.field_path.clone()), expected_name: None, replacement_bytes: replacement.len() as u64, replacement_blake3: blake3::hash(&replacement).to_hex().to_string(), + replacement: None, + expected_value: patch.expected_value.clone(), + replacement_value: Some(patch.replacement.clone()), text_unit_id: None, source_text_blake3: None, translation_provider: None, @@ -1129,17 +1775,121 @@ fn default_patch_operation_kind() -> String { "unityfs_text_asset".to_string() } +fn generic_manifest_operation( + operation: &LocalizedPatchOperation, + sequence: u32, +) -> anyhow::Result { + let payload = + match operation.patch_kind.as_str() { + "unityfs_text_asset" => bat_patch::PatchManifestOperationPayload::UnityFsTextAsset { + serialized_file_path: operation.serialized_file_path.clone(), + path_id: operation.path_id, + expected_name: operation.expected_name.clone(), + replacement: operation.replacement.clone().ok_or_else(|| { + anyhow::anyhow!("localized TextAsset manifest 缺少 replacement") + })?, + }, + "unityfs_string_field" => { + bat_patch::PatchManifestOperationPayload::UnityFsStringField { + serialized_file_path: operation.serialized_file_path.clone(), + path_id: operation.path_id, + field_path: operation.field_path.clone().ok_or_else(|| { + anyhow::anyhow!("localized string operation 缺少 field_path") + })?, + expected_value: operation.expected_value.as_ref().and_then( + |value| match value { + UnitySerializedReplacementValue::String(value) => Some(value.clone()), + _ => None, + }, + ), + replacement: String::from_utf8(operation.replacement.clone().ok_or_else( + || anyhow::anyhow!("localized string manifest 缺少 replacement"), + )?)?, + } + } + "unityfs_field" => bat_patch::PatchManifestOperationPayload::UnityFsField { + serialized_file_path: operation.serialized_file_path.clone(), + path_id: operation.path_id, + field_path: operation.field_path.clone().ok_or_else(|| { + anyhow::anyhow!("localized semantic operation 缺少 field_path") + })?, + expected_value: operation + .expected_value + .as_ref() + .map(serde_json::to_value) + .transpose()?, + replacement: serde_json::to_value( + operation.replacement_value.as_ref().ok_or_else(|| { + anyhow::anyhow!("localized semantic operation 缺少 replacement_value") + })?, + )?, + }, + other => { + return Err(anyhow::anyhow!( + "localized operation 不是受支持的 generic patch kind:{other}" + )) + } + }; + Ok(bat_patch::PatchManifestOperation { + sequence, + source_blake3: operation.source_blake3.clone(), + source_size: operation.source_bytes, + archive_entry: operation.archive_entry.clone(), + payload, + provenance: localized_provenance(operation), + }) +} + +fn localized_provenance( + operation: &LocalizedPatchOperation, +) -> Option { + let provenance = bat_patch::PatchManifestProvenance { + text_unit_id: operation.text_unit_id.clone(), + source_text_blake3: operation.source_text_blake3.clone(), + translation_provider: operation.translation_provider.clone(), + provider_run_id: operation.provider_run_id.clone(), + translation_source_kind: operation.translation_source_kind.clone(), + translation_memory_record_id: operation.translation_memory_record_id.clone(), + review_status: operation.review_status.clone(), + glossary_qa: operation + .glossary_qa + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()), + glossary_override: operation + .glossary_override + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()), + }; + [ + provenance.text_unit_id.as_ref(), + provenance.source_text_blake3.as_ref(), + provenance.translation_provider.as_ref(), + provenance.provider_run_id.as_ref(), + provenance.translation_source_kind.as_ref(), + provenance.translation_memory_record_id.as_ref(), + provenance.review_status.as_ref(), + ] + .iter() + .any(Option::is_some) + .then_some(provenance) +} + impl Default for LocalizedPatchOperation { fn default() -> Self { Self { archive_entry: None, patch_kind: default_patch_operation_kind(), + source_blake3: None, + source_bytes: None, serialized_file_path: String::new(), path_id: 0, field_path: None, expected_name: None, replacement_bytes: 0, replacement_blake3: String::new(), + replacement: None, + expected_value: None, + replacement_value: None, text_unit_id: None, source_text_blake3: None, translation_provider: None, @@ -1257,6 +2007,48 @@ fn verify_patch_manifest_files( operation_count )); } + if let Some(generic_manifest) = manifest.patch_manifest.as_ref() { + bat_patch::validate_patch_manifest(generic_manifest) + .map_err(|error| anyhow::anyhow!("嵌套 generic patch manifest 无效:{error}"))?; + if generic_manifest.source_version != manifest.official_release_id { + return Err(anyhow::anyhow!( + "generic manifest source version={} 与 localized manifest official release={} 不一致", + generic_manifest.source_version, + manifest.official_release_id + )); + } + if generic_manifest.target_version != manifest.localized_release_id { + return Err(anyhow::anyhow!( + "generic manifest target version={} 与 localized manifest release={} 不一致", + generic_manifest.target_version, + manifest.localized_release_id + )); + } + if generic_manifest.files.len() != manifest.files.len() + || generic_manifest + .files + .iter() + .zip(&manifest.files) + .any(|(generic, localized)| { + generic.path.to_string_lossy() != localized.path + || generic.source_blake3 != localized.original_blake3 + || generic.target_blake3 != localized.localized_blake3 + || generic.source_size != localized.original_bytes + || generic.target_size != localized.localized_bytes + || generic.operations != localized.operations + }) + { + return Err(anyhow::anyhow!( + "generic manifest 与 localized manifest 文件/操作记录不一致" + )); + } + verify_generic_manifest_outputs( + official_release_root, + localized_release_root, + generic_manifest, + unzip_command, + )?; + } Ok(LocalizedPatchIntegrity { verified_changed_file_count: manifest.files.len(), verified_text_asset_operation_count: operation_count, @@ -1269,6 +2061,7 @@ fn rewrite_zip_bundle( operations: &[LocalizedPatchInput], unzip_command: &Path, zip_command: &Path, + generic_operations: Option<&[bat_patch::PatchManifestOperation]>, ) -> anyhow::Result<(Vec, Vec)> { let archive_temp = tempfile::tempdir()?; let archive_path = archive_temp.path().join("source.zip"); @@ -1291,7 +2084,7 @@ fn rewrite_zip_bundle( let mut manifest_operations = Vec::with_capacity(operations.len()); let mut patched_entries = BTreeMap::>::new(); - for operation in operations { + for (index, operation) in operations.iter().enumerate() { let archive_entry = operation.archive_entry().ok_or_else(|| { anyhow::anyhow!("ZIP patch 缺少 archive_entry:{}", operation.bundle_path()) })?; @@ -1312,12 +2105,21 @@ fn rewrite_zip_bundle( } else { fs::read(&entry_path)? }; + if let Some(generic_operations) = generic_operations { + let generic_operation = generic_operations.get(index).ok_or_else(|| { + anyhow::anyhow!("generic patch manifest ZIP operation 数量不一致") + })?; + verify_generic_operation_source(¤t, generic_operation)?; + } let patched = operation.apply(¤t).map_err(|error| { anyhow::anyhow!("{}!{}: {error}", operation.bundle_path(), archive_entry) })?; fs::write(&entry_path, &patched)?; patched_entries.insert(archive_entry.to_string(), patched); - manifest_operations.push(operation.manifest_operation()?); + let mut manifest_operation = operation.manifest_operation()?; + manifest_operation.source_blake3 = Some(blake3::hash(¤t).to_hex().to_string()); + manifest_operation.source_bytes = Some(current.len() as u64); + manifest_operations.push(manifest_operation); } let output_path = archive_temp.path().join("localized.zip"); @@ -1470,9 +2272,50 @@ fn verify_archive_operation_replacement( } } } - // Semantic field replacements retain structure and are already - // verified by the field patch operation before the archive rewrite. - _ => return Ok(()), + "unityfs_field" => { + let field_path = operation.field_path.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "ZIP 内 UnityFS semantic operation 缺少 field_path:{}!{}:{}", + archive_entry, + operation.serialized_file_path, + operation.path_id + ) + })?; + let fields = serialized_file.fields_for_object(operation.path_id)?; + let value = find_archive_field_value(&fields, field_path).ok_or_else(|| { + anyhow::anyhow!( + "ZIP 内 UnityFS semantic 字段不存在:{}!{}:{}:{}", + archive_entry, + operation.serialized_file_path, + operation.path_id, + field_path + ) + })?; + let expected = operation.replacement_value.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "ZIP 内 UnityFS semantic operation 缺少 replacement_value,不能完成最终语义校验:{}!{}:{}:{}", + archive_entry, + operation.serialized_file_path, + operation.path_id, + field_path + ) + })?; + if !expected.matches_serialized_value(value) { + return Err(anyhow::anyhow!( + "ZIP 内 UnityFS semantic replacement 校验失败:{}!{}:{}:{}", + archive_entry, + operation.serialized_file_path, + operation.path_id, + field_path + )); + } + return Ok(()); + } + other => { + return Err(anyhow::anyhow!( + "不支持的 ZIP UnityFS manifest operation:{other}" + )); + } }; let actual_hash = blake3::hash(&bytes).to_hex().to_string(); @@ -1843,6 +2686,10 @@ mod tests { data.extend_from_slice(&value.to_le_bytes()); } + fn push_i32_le(data: &mut Vec, value: i32) { + data.extend_from_slice(&value.to_le_bytes()); + } + fn push_i16_le(data: &mut Vec, value: i16) { data.extend_from_slice(&value.to_le_bytes()); } @@ -1901,6 +2748,76 @@ mod tests { file } + fn synthetic_serialized_monobehaviour() -> Vec { + let mut object_data = Vec::new(); + push_u32_le(&mut object_data, 5); + object_data.extend_from_slice(b"hello"); + align_vec(&mut object_data, 4); + + let mut strings = Vec::new(); + let root_type = strings.len(); + strings.extend_from_slice(b"MonoBehaviour\0"); + let root_name = strings.len(); + strings.push(0); + let field_type = strings.len(); + strings.extend_from_slice(b"string\0"); + let field_name = strings.len(); + strings.extend_from_slice(b"message\0"); + + let mut metadata = Vec::new(); + metadata.extend_from_slice(b"2021.3.56f2\0"); + push_i32_le(&mut metadata, 19); + metadata.push(1); + push_i32_le(&mut metadata, 1); + push_i32_le(&mut metadata, 114); + metadata.push(0); + push_i16_le(&mut metadata, 0); + metadata.extend_from_slice(&[0; 16]); + metadata.extend_from_slice(&[0; 16]); + push_i32_le(&mut metadata, 2); + push_i32_le(&mut metadata, strings.len() as i32); + for (level, type_offset, name_offset) in [ + (0u8, root_type as i32, root_name as i32), + (1u8, field_type as i32, field_name as i32), + ] { + metadata.extend_from_slice(&1u16.to_le_bytes()); + metadata.push(level); + metadata.push(0); + push_i32_le(&mut metadata, type_offset); + push_i32_le(&mut metadata, name_offset); + push_i32_le(&mut metadata, -1); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 0); + push_u64_le(&mut metadata, 0); + } + metadata.extend_from_slice(&strings); + push_i32_le(&mut metadata, 0); + push_i32_le(&mut metadata, 1); + align_vec(&mut metadata, 4); + metadata.extend_from_slice(&1i64.to_le_bytes()); + push_u64_le(&mut metadata, 0); + push_u32_le(&mut metadata, object_data.len() 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.push(0); + file.extend_from_slice(&[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_unityfs(payload: &[u8]) -> Vec { let mut blocks_info = vec![0; 16]; blocks_info.extend_from_slice(&1i32.to_be_bytes()); @@ -1952,12 +2869,17 @@ mod tests { text_asset_operations: vec![LocalizedPatchOperation { archive_entry: None, patch_kind: "unityfs_text_asset".to_string(), + source_blake3: Some(blake3::hash(source).to_hex().to_string()), + source_bytes: Some(source.len() as u64), serialized_file_path: "CAB-asset".to_string(), path_id: 1, field_path: None, expected_name: Some("Text".to_string()), replacement_bytes: target.len() as u64, replacement_blake3: blake3::hash(target).to_hex().to_string(), + replacement: Some(target.to_vec()), + expected_value: None, + replacement_value: None, text_unit_id: Some("unit-1".to_string()), source_text_blake3: Some(blake3::hash(source).to_hex().to_string()), translation_provider: Some("mock".to_string()), @@ -1968,14 +2890,16 @@ mod tests { glossary_qa: None, glossary_override: None, }], + operations: Vec::new(), }], + patch_manifest: None, rollback: LocalizedPatchRollbackInfo { previous_current_target: Some(PathBuf::from("versions/previous")), remove_version_path: PathBuf::from("versions/localized-v1"), }, }; - let patch_manifest = manifest.to_patch_manifest(); + let patch_manifest = manifest.to_patch_manifest().unwrap(); assert_eq!(patch_manifest.version, bat_patch::PATCH_MANIFEST_VERSION); assert_eq!(patch_manifest.patch_id, "localized-v1"); @@ -2030,6 +2954,168 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn generic_manifest_publishes_binary_json_and_text_files() { + let temp = TempDir::new().unwrap(); + let official = temp.path().join("official-release"); + let target = temp.path().join("target-release"); + let localized = temp.path().join("localized"); + fs::create_dir_all(&official).unwrap(); + fs::create_dir_all(&target).unwrap(); + + let binary_source = b"binary-before"; + let binary_target = b"binary-after"; + let json_source = br#"{"value":0}"#; + let json_target = br#"{"value":1}"#; + let text_source = "old text\n"; + let text_target = "new text\n"; + fs::write(official.join("data.bin"), binary_source).unwrap(); + fs::write(target.join("data.bin"), binary_target).unwrap(); + fs::write(official.join("data.json"), json_source).unwrap(); + fs::write(target.join("data.json"), json_target).unwrap(); + fs::write(official.join("text.txt"), text_source).unwrap(); + fs::write(target.join("text.txt"), text_target).unwrap(); + + let manifest = bat_patch::build_patch_manifest( + &official, + &target, + "localized-v1", + "official-v1", + "localized-v1", + vec![ + bat_patch::PatchManifestBuildFile { + path: PathBuf::from("data.bin"), + patch_kind: bat_patch::PatchKind::Binary, + operations: vec![bat_patch::PatchManifestOperation { + sequence: 0, + source_blake3: None, + source_size: None, + archive_entry: None, + payload: bat_patch::PatchManifestOperationPayload::Binary { + patch: bat_patch::binary::diff(binary_source, binary_target), + }, + provenance: None, + }], + }, + bat_patch::PatchManifestBuildFile { + path: PathBuf::from("data.json"), + patch_kind: bat_patch::PatchKind::Json, + operations: vec![bat_patch::PatchManifestOperation { + sequence: 0, + source_blake3: None, + source_size: None, + archive_entry: None, + payload: bat_patch::PatchManifestOperationPayload::Json { + patch: serde_json::json!([{ + "op": "replace", + "path": "/value", + "value": 1 + }]), + }, + provenance: None, + }], + }, + bat_patch::PatchManifestBuildFile { + path: PathBuf::from("text.txt"), + patch_kind: bat_patch::PatchKind::Text, + operations: vec![bat_patch::PatchManifestOperation { + sequence: 0, + source_blake3: None, + source_size: None, + archive_entry: None, + payload: bat_patch::PatchManifestOperationPayload::Text { + patch: bat_patch::text::diff(text_source, text_target), + }, + provenance: None, + }], + }, + ], + bat_patch::PatchRollback { + previous_current_target: None, + remove_target_path: Some(PathBuf::from("versions/localized-v1")), + }, + ) + .unwrap(); + + let report = LocalizedPatchService::new() + .publish( + &LocalizedPatchConfig::new(&official, &localized, "official-v1", Vec::new()) + .with_manifest(manifest) + .with_localized_release_id("localized-v1"), + ) + .unwrap(); + + assert_eq!( + fs::read(report.version_path.join("data.bin")).unwrap(), + binary_target + ); + assert_eq!( + fs::read(report.version_path.join("data.json")).unwrap(), + json_target + ); + assert_eq!( + fs::read_to_string(report.version_path.join("text.txt")).unwrap(), + text_target + ); + let generic = report.manifest.patch_manifest.as_ref().unwrap(); + assert_eq!(generic.files.len(), 3); + assert_eq!( + generic + .files + .iter() + .flat_map(|file| file.operations.iter()) + .map(|operation| operation.patch_kind()) + .collect::>(), + vec![ + bat_patch::PatchKind::Binary, + bat_patch::PatchKind::Json, + bat_patch::PatchKind::Text + ] + ); + assert!(report.integrity.current_points_to_release); + } + + #[cfg(unix)] + #[test] + fn generic_manifest_failure_removes_only_unpublished_staging() { + let temp = TempDir::new().unwrap(); + let official = temp.path().join("official-release"); + let localized = temp.path().join("localized"); + fs::create_dir_all(&official).unwrap(); + fs::write(official.join("data.bin"), b"official").unwrap(); + let manifest = bat_patch::PatchManifest { + version: bat_patch::PATCH_MANIFEST_VERSION, + patch_id: "localized-v1".to_string(), + source_version: "wrong-official".to_string(), + target_version: "localized-v1".to_string(), + files: Vec::new(), + rollback: bat_patch::PatchRollback { + previous_current_target: None, + remove_target_path: Some(PathBuf::from("versions/localized-v1")), + }, + }; + + let error = LocalizedPatchService::new() + .publish( + &LocalizedPatchConfig::new(&official, &localized, "official-v1", Vec::new()) + .with_manifest(manifest) + .with_localized_release_id("localized-v1"), + ) + .unwrap_err(); + + assert!(error.to_string().contains("source version")); + assert!(!localized + .join(LOCALIZED_STAGING_DIR) + .join("localized-v1") + .exists()); + assert!(!localized + .join(LOCALIZED_VERSIONS_DIR) + .join("localized-v1") + .exists()); + assert!(!localized.join(LOCALIZED_CURRENT_LINK).exists()); + } + #[cfg(unix)] #[test] fn publishes_and_reparses_bundle_nested_in_zip() { @@ -2093,6 +3179,86 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn final_zip_semantic_verification_checks_location_field_and_value() { + let temp = TempDir::new().unwrap(); + let zip_source = temp.path().join("zip-source"); + fs::create_dir_all(zip_source.join("nested")).unwrap(); + let original_bundle = synthetic_unityfs(&synthetic_serialized_monobehaviour()); + let patched_bundle = patch_unityfs_field( + &original_bundle, + &FieldPatch { + serialized_file_path: "CAB-asset".to_string(), + path_id: 1, + field_path: "message".to_string(), + expected_value: Some(UnitySerializedReplacementValue::String("hello".to_string())), + replacement: UnitySerializedReplacementValue::String("你好".to_string()), + }, + ) + .unwrap(); + fs::write(zip_source.join("nested/CAB-story"), patched_bundle).unwrap(); + let archive_path = temp.path().join("localized.zip"); + let status = Command::new("zip") + .current_dir(&zip_source) + .arg("-q") + .arg("-r") + .arg(&archive_path) + .arg(".") + .status() + .unwrap(); + assert!(status.success()); + + let mut operation = LocalizedPatchOperation::from_field_patch( + &FieldPatch { + serialized_file_path: "CAB-asset".to_string(), + path_id: 1, + field_path: "message".to_string(), + expected_value: Some(UnitySerializedReplacementValue::String("hello".to_string())), + replacement: UnitySerializedReplacementValue::String("你好".to_string()), + }, + None, + ) + .unwrap(); + operation.archive_entry = Some("nested/CAB-story".to_string()); + let operations = vec![&operation]; + verify_zip_operations(Path::new("unzip"), &archive_path, &operations, true).unwrap(); + + let mut wrong_serialized_file = operation.clone(); + wrong_serialized_file.serialized_file_path = "CAB-other".to_string(); + let error = verify_zip_operations( + Path::new("unzip"), + &archive_path, + &[&wrong_serialized_file], + true, + ) + .unwrap_err(); + assert!(error.to_string().contains("serialized file")); + + let mut wrong_field_path = operation.clone(); + wrong_field_path.field_path = Some("wrong".to_string()); + let error = verify_zip_operations( + Path::new("unzip"), + &archive_path, + &[&wrong_field_path], + true, + ) + .unwrap_err(); + assert!(error.to_string().contains("字段不存在")); + + let mut wrong_replacement = operation; + wrong_replacement.replacement_value = + Some(UnitySerializedReplacementValue::String("错误".to_string())); + let error = verify_zip_operations( + Path::new("unzip"), + &archive_path, + &[&wrong_replacement], + true, + ) + .unwrap_err(); + assert!(error.to_string().contains("replacement 校验失败")); + } + #[cfg(unix)] #[test] fn publishes_a_separate_localized_release_atomically() { diff --git a/internal/api/admin.go b/internal/api/admin.go index 30ddbd5..2a8865e 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -1306,8 +1306,19 @@ func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryC 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 } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 1c8cad3..3778349 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1954,6 +1954,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { {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 { @@ -2041,6 +2042,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) { diff --git a/internal/api/openapi.go b/internal/api/openapi.go index f2b9d77..e44599a 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -730,6 +730,8 @@ paths: type: string from_worker: type: boolean + patch_manifest: + type: string localized_release_id: type: string responses: diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index df2ec15..da026b6 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -637,10 +637,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"` } diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index c886588..d1cf690 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -797,6 +797,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" {