diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index e80990c..65fad7e 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -46,6 +46,12 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人 按当前 QA 精确校验 identity。`translation.glossary.*` 已通过 `bat.sock` 暴露,Go `bat-api` 仅做鉴权 typed forwarding。 +`localized.status` 现将 generic manifest schema/contract 与已发布 artifact integrity +分开报告;current、state 和 identity 存在但文件被截断或手工修改时返回 +`localized.degraded`,只读检查不会自动回滚、删除或修复。双 release 的 +`release.status/list/distribution/cleanup` 已由 Rust 从既有状态、manifest、文件系统和 +CAS/reference 元数据统一生成,Go 仅 typed 转发。 + --- ## 2. 权威文档入口 @@ -152,11 +158,14 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人 - `OfficialResourcePullService`:官方 URL 拒绝策略、目标路径映射、下载 manifest、下载 quarantine、`.part` 续传、curl 代理配置、403/404/5xx 分类重试、ZIP 结构校验、官方 seed `.hash` 校验、本地全量 verify。 - `OfficialUpdateService`:官方 metadata auto-discover、bootstrap cache、snapshot diff、marker diff、本地 audit/repair、失败 staging 恢复。 - `bat`:正式 CLI binary,支持 one-shot、`--proxy` / `--no-proxy`、`--watch`、`--daemon`、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor` 和 `clean-stable`。 +- `release_ops.rs`:从既有 official/localized state、manifest、filesystem 和 CAS reference + 元数据生成双 release `status/list/distribution/cleanup`;默认 official 分发,localized + 和历史 release 仅在 Rust 完整性验证通过后可选,cleanup 使用 dry-run `plan_id` 和执行前重验证。 - `report_output.rs`、`terminal_output.rs`:分别负责结果报告渲染和前台终端诊断、帮助、进度及结构化日志输出。 待完成: -- 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary、复杂 AssetBundle fixture 和 generic manifest 发布资源视图。 +- 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary 和复杂 AssetBundle fixture;generic manifest V1 与双 release 运维 V1 已完成。 - 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`;实际运行报告由脚本写入隔离输出目录。 - 增加更多权限和极端文件系统场景测试。 @@ -194,7 +203,7 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人 待完成: - 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包。 -- 未见样本驱动的复杂 AssetBundle 重打包、更多双 release 查询/分发视图和清理策略;当前 generic manifest 只承诺已验证的 Binary/JSON/Text 与 UnityFS 结构,不等价于任意整体 AssetBundle 重打包。 +- 未见样本驱动的复杂 AssetBundle 重打包;当前 generic manifest 和双 release 运维 V1 只承诺已验证的 Binary/JSON/Text、UnityFS 结构及 Rust-owned release 查询/分发/安全清理,不等价于任意整体 AssetBundle 重打包。 - `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。 ### `bat-ffi` @@ -228,7 +237,7 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人 | 角色 | 所有者 | 状态 | |---|---|---| | 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 | -| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + CDN MVP + RPC 周期刷新/诊断 + readiness + 内嵌 dashboard | +| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + official/localized/historical verified CDN MVP + RPC 周期刷新/诊断 + readiness + release 管理转发 + 内嵌 dashboard | | daemon RPC client | `internal/backendrpc` | 完成 | | 试验 CLI | `cmd/bat` → `bin/bat-go` | 非产品 | | FFI | `internal/ffi` | 可选 | @@ -305,7 +314,7 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远 后续工程顺序: 1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。 -2. 继续通用 Patch:manifest 驱动、双 release 查询和清理策略。 +2. 继续通用 Patch:真实样本驱动的复杂 AssetBundle 兼容;双 release 查询、分发、rollback 边界和安全清理 V1 已完成。 3. 继续资源查询和翻译基础设施:更丰富的查询和 Provider 扩展体系。 4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。 @@ -317,5 +326,5 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远 HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard 和 `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、 通用 Binary/JSON/Text Patch 基础、generic manifest 和受支持 localized patch 发布/rollback 可用; - 复杂 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和更多双 release 运维视图未完成。 -- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、双 release 运维视图,以及真实官方资源长期运行验证。 + 复杂 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和更高阶 release retention 未完成;双 release 运维 V1 已完成。 +- **下一工程里程碑**:复杂 AssetBundle 解析和重打包,以及真实官方资源长期运行验证。 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 88fc496..9695e23 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -32,7 +32,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。 5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。 6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat`。 -7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/restart/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`translation.*`、`localized.status`、`catalog.*`、`task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行。 +7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/restart/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`translation.*`、`localized.status`、`release.status/list/distribution/cleanup`、`catalog.*`、`task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行。 8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。 9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;`translation.proofread` 可把汉化 workflow 标记为人工校对中,但不会覆盖已发布的汉化 release。 10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。 @@ -306,7 +306,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 3. 实现客户端发现、路径校验、备份、应用、回滚。 4. 实现 `patch build`、`patch apply`、`patch rollback`、`verify`。 5. 实现 dry-run 和安全检查。 -6. 在 generic Patch manifest 已统一当前支持类型后,继续补复杂 AssetBundle 兼容和双 release 运维视图。 +6. generic Patch manifest V1 已统一当前支持类型;双 release 的查询、分发选择和安全 cleanup V1 已由 Rust `bat` 持有,复杂 AssetBundle 兼容继续由真实样本驱动。 +7. `release.status` / `release.list` 提供 official/localized current 与历史 release 统一视图;`release.distribution` 只选择已验证资源,默认 official;`release.cleanup` 采用 dry-run `plan_id`、执行前重验证和 CAS/reference 保护,rollback 保持独立。 验收标准: @@ -387,7 +388,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 ## 5. 推荐执行顺序 -近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、复杂 AssetBundle 重打包、双 release 运维视图和真实官方资源长期运行验证。 +近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、复杂 AssetBundle 重打包和真实官方资源长期运行验证。 建议顺序: @@ -461,7 +462,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。 -已完成的是稳定基线、架构骨架、部分接口、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 运维视图和官方资源长期运行报告。 +已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、generic manifest V1、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback、Rust-owned 双 release 查询/分发/cleanup V1,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、真实版本与复杂 AssetBundle 兼容和官方资源长期运行报告。 --- diff --git a/USERGUIDE.md b/USERGUIDE.md index 1385d8a..5e45301 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -501,6 +501,10 @@ Glossary 相关定义变化会使受影响 override 失效,无关术语变化 | `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` | | `resource.manifest` / `resource.list` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000) | | `resource.index` | ✅ | 查询现有 SQLite ResourceRepository 索引,支持资源类型、hash、路径模式、release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 过滤 | +| `release.status` | ✅ | 查询 official/localized 当前与历史 release 的统一状态、来源关系和完整性 | +| `release.list` | ✅ | 按 namespace 查询历史 release 摘要,识别 current、legacy、stale 和 damaged | +| `release.distribution` | ✅ | 选择已验证的 official 或 localized 当前/历史 release;默认 official,不跨 channel fallback | +| `release.cleanup` | ✅ | 先 dry-run 生成 `plan_id`,再由 Rust 重验证引用后清理无引用历史 release | | `parse.status` | ✅ | 查询当前 release 的解析缓存、TextUnit 索引和队列摘要 | | `parse.text_units` / `parse.errors` | ✅ | 查询当前 release 的 TextUnit 明细和解析错误 | | `translation.tasks` | ✅ | 查询离线 TextUnit 翻译任务及 worker 状态 | @@ -523,10 +527,23 @@ Glossary 相关定义变化会使受影响 override 失效,无关术语变化 | 未知方法 | — | `BAT-ERR-700001`(unknown method) | 只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` / -`resource.index` / `parse.*` / `translation.tasks` / `translation.handoff` / +`resource.index` / `release.status` / `release.list` / `release.distribution` / `parse.*` / `translation.tasks` / `translation.handoff` / `localized.status` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本 或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。 +`localized.status` 会分别返回 `patch_manifest_contract_status` 和 +`artifact_integrity_status`。current 仍存在但 release 文件被截断或手工修改时, +状态为 `degraded` / `localized.degraded`,不会自动回滚、删除或修复。`release.distribution` +默认选择 official;只有 Rust 已验证的当前或显式历史 release 可分发,localized、 +staging、损坏或路径不安全的 release 不会回退到另一 channel。 + +`release.list` 的每项摘要还会返回 `rollback_available`、`stale`、`damaged`、 +`referenced` 和 `unknown`,便于区分可回滚、损坏和证据不足的历史 release。 + +双 release cleanup 不是独立 CLI:通过 `release.cleanup` 先执行 dry-run,再把返回的 +`plan_id` 传给 `execute=true`。Rust 会保护 current、rollback、staging、source、 +状态/manifest/CAS 引用和未知归属对象;rollback 仍使用独立的 `localized.rollback`。 + ### 任务模型 `resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task--", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录: @@ -567,4 +584,16 @@ printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset": # 触发本地资源审计+修复任务 printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \ | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock + +# 查询双 release 状态,并选择已验证的 localized release +printf '{"jsonrpc":"2.0","id":7,"method":"release.status"}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock +printf '{"jsonrpc":"2.0","id":8,"method":"release.distribution","params":{"channel":"localized"}}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock + +# cleanup 必须先 dry-run,再使用同一 plan_id 执行 +printf '{"jsonrpc":"2.0","id":9,"method":"release.cleanup","params":{"execute":false}}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock +printf '{"jsonrpc":"2.0","id":10,"method":"release.cleanup","params":{"execute":true,"plan_id":""}}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock ``` diff --git a/api/openapi/bat-api.yaml b/api/openapi/bat-api.yaml index dfbd701..6f40c78 100644 --- a/api/openapi/bat-api.yaml +++ b/api/openapi/bat-api.yaml @@ -72,6 +72,52 @@ paths: responses: "200": description: Release summary. + /v1/releases: + get: + summary: Rust-owned official and localized release history + parameters: + - name: channel + in: query + schema: + type: string + enum: [official, localized] + responses: + "200": + description: Release history and manifest/artifact integrity summaries. + "503": + description: Rust bat release backend is unavailable. + /v1/distribution: + get: + summary: Select a verified official or localized release for distribution + parameters: + - name: channel + in: query + schema: + type: string + enum: [official, localized] + default: official + - name: release_id + in: query + schema: + type: string + - name: offset + in: query + schema: + type: integer + minimum: 0 + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + responses: + "200": + description: Rust-verified selected release and resource manifest page. + "409": + description: Selected release is missing, stale, damaged, or not distributable. + "503": + description: Rust bat release backend is unavailable. /v1/resources: get: summary: Paginated resource manifest entries @@ -544,6 +590,32 @@ paths: description: Missing or invalid admin token. "503": description: Rust bat localized backend is unavailable. + /admin/releases/status: + get: + summary: Read the unified Rust-owned release status view + responses: + "200": + description: Official/localized current relation and integrity status. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat release backend is unavailable. + /admin/releases: + get: + summary: Read Rust-owned historical release summaries + parameters: + - name: channel + in: query + schema: + type: string + enum: [official, localized] + responses: + "200": + description: Historical release summaries. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat release backend is unavailable. /admin/control/{action}: post: summary: Forward an allowlisted control or schedule action to Rust bat @@ -553,7 +625,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback] + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback, release-cleanup] requestBody: required: false content: diff --git a/crates/bat-patch/src/manifest.rs b/crates/bat-patch/src/manifest.rs index 07bdc5e..c7acb9e 100644 --- a/crates/bat-patch/src/manifest.rs +++ b/crates/bat-patch/src/manifest.rs @@ -347,7 +347,11 @@ pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> { file.path.display() ))); } - let mut targets = std::collections::BTreeSet::new(); + let unity_operations = file + .operations + .iter() + .filter_map(unity_operation_target) + .collect::>(); for (expected_sequence, operation) in file.operations.iter().enumerate() { operation.payload.validate()?; if operation.sequence != expected_sequence as u32 { @@ -391,15 +395,6 @@ pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> { 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 {}", @@ -408,34 +403,153 @@ pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> { ))); } } + for (index, left) in unity_operations.iter().enumerate() { + for right in unity_operations.iter().skip(index + 1) { + if unity_operation_targets_conflict(left, right) { + return Err(PatchError::ApplyFailed(format!( + "patch manifest contains overlapping UnityFS targets in {}: {} and {}", + file.path.display(), + left.describe(), + right.describe() + ))); + } + } + } } Ok(()) } -fn unity_operation_target(operation: &PatchManifestOperation) -> Option<(Option<&str>, &str, i64)> { - let (serialized_file_path, path_id) = match &operation.payload { +#[derive(Debug, Clone, Copy)] +enum UnityOperationTarget<'a> { + WholeObject { + archive_entry: Option<&'a str>, + serialized_file_path: &'a str, + path_id: i64, + }, + Field { + archive_entry: Option<&'a str>, + serialized_file_path: &'a str, + path_id: i64, + field_path: &'a str, + }, +} + +impl UnityOperationTarget<'_> { + fn archive_entry(&self) -> Option<&str> { + match self { + Self::WholeObject { archive_entry, .. } | Self::Field { archive_entry, .. } => { + *archive_entry + } + } + } + + fn serialized_file_path(&self) -> &str { + match self { + Self::WholeObject { + serialized_file_path, + .. + } + | Self::Field { + serialized_file_path, + .. + } => serialized_file_path, + } + } + + fn path_id(&self) -> i64 { + match self { + Self::WholeObject { path_id, .. } | Self::Field { path_id, .. } => *path_id, + } + } + + fn describe(self) -> String { + match self { + Self::WholeObject { + archive_entry, + serialized_file_path, + path_id, + } => format!( + "archive={archive_entry:?}, serialized_file={serialized_file_path}, path_id={path_id}, object" + ), + Self::Field { + archive_entry, + serialized_file_path, + path_id, + field_path, + } => format!( + "archive={archive_entry:?}, serialized_file={serialized_file_path}, path_id={path_id}, field={field_path}" + ), + } + } +} + +fn unity_operation_target(operation: &PatchManifestOperation) -> Option> { + let archive_entry = operation.archive_entry.as_deref(); + match &operation.payload { PatchManifestOperationPayload::UnityFsTextAsset { serialized_file_path, path_id, .. - } => (serialized_file_path, *path_id), + } => Some(UnityOperationTarget::WholeObject { + archive_entry, + serialized_file_path, + path_id: *path_id, + }), PatchManifestOperationPayload::UnityFsStringField { serialized_file_path, path_id, + field_path, .. } | PatchManifestOperationPayload::UnityFsField { serialized_file_path, path_id, + field_path, .. - } => (serialized_file_path, *path_id), - _ => return None, - }; - Some(( - operation.archive_entry.as_deref(), - serialized_file_path.as_str(), - path_id, - )) + } => Some(UnityOperationTarget::Field { + archive_entry, + serialized_file_path, + path_id: *path_id, + field_path, + }), + _ => None, + } +} + +fn unity_operation_targets_conflict( + left: &UnityOperationTarget<'_>, + right: &UnityOperationTarget<'_>, +) -> bool { + if left.archive_entry() != right.archive_entry() + || left.serialized_file_path() != right.serialized_file_path() + || left.path_id() != right.path_id() + { + return false; + } + match (left, right) { + (UnityOperationTarget::WholeObject { .. }, _) + | (_, UnityOperationTarget::WholeObject { .. }) => true, + ( + UnityOperationTarget::Field { + field_path: left_path, + .. + }, + UnityOperationTarget::Field { + field_path: right_path, + .. + }, + ) => field_paths_overlap(left_path, right_path), + } +} + +fn field_paths_overlap(left: &str, right: &str) -> bool { + left == right || is_field_path_parent(left, right) || is_field_path_parent(right, left) +} + +fn is_field_path_parent(parent: &str, child: &str) -> bool { + child + .strip_prefix(parent) + .is_some_and(|suffix| suffix.starts_with('.') || suffix.starts_with('[')) } /// Builds a manifest from release-root bytes and ordered operation payloads. @@ -477,13 +591,8 @@ pub fn build_patch_manifest( 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 direct_operations = !has_unity_operation && !has_archive_operation; let mut current = source.clone(); let operations = file .operations @@ -491,39 +600,58 @@ pub fn build_patch_manifest( .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 direct_operations && operation.source_blake3.is_none() && operation_count > 1 { + operation.source_blake3 = Some(blake3_hex(¤t)); + operation.source_size = Some(current.len() as u64); + } else if !direct_operations + && operation_count > 1 + && operation.source_blake3.is_none() { - if expected_hash != blake3_hex(¤t) - || expected_size != current.len() as u64 + return Err(PatchError::ApplyFailed(format!( + "manifest builder requires source preconditions for multiple non-direct operations: {}", + file.path.display() + ))); + } + if direct_operations { + if let (Some(expected_hash), Some(expected_size)) = + (operation.source_blake3.as_deref(), operation.source_size) { - return Err(PatchError::ApplyFailed(format!( - "manifest builder operation source precondition mismatch: {} operation {}", - file.path.display(), - sequence - ))); + if expected_hash != blake3_hex(¤t) + || expected_size != current.len() as u64 + { + return Err(PatchError::ApplyFailed(format!( + "manifest builder operation source precondition mismatch: {} operation {}", + file.path.display(), + sequence + ))); + } + } + current = apply_direct_payload(¤t, &operation.payload)?; + } else if operation_count == 1 { + if let (Some(expected_hash), Some(expected_size)) = + (operation.source_blake3.as_deref(), operation.source_size) + { + if expected_hash != blake3_hex(&source) + || expected_size != source.len() as u64 + { + return Err(PatchError::ApplyFailed(format!( + "manifest builder operation source precondition mismatch: {} operation {}", + file.path.display(), + sequence + ))); + } } } - if !has_unity_operation && !has_archive_operation { - current = apply_direct_payload(¤t, &operation.payload)?; - } + /* + * Non-direct operations are produced by the owning adapter. + * Their source preconditions describe adapter-produced + * intermediate bytes, which this crate cannot reconstruct. + */ operation.sequence = sequence as u32; Ok(operation) }) .collect::>>()?; - if !has_unity_operation && !has_archive_operation && current != target { + if direct_operations && current != target { return Err(PatchError::ApplyFailed(format!( "manifest builder operations do not produce target bytes: {}", file.path.display() @@ -852,45 +980,145 @@ mod tests { } #[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, - }, - ]; + fn validate_patch_manifest_allows_sibling_fields_on_one_unity_object() { + let manifest = unity_manifest(vec![ + unity_string_operation(None, "first", 0), + unity_field_operation(None, "second", 1), + ]); + + validate_patch_manifest(&manifest).unwrap(); + } + + #[test] + fn validate_patch_manifest_rejects_duplicate_unity_field() { + let manifest = unity_manifest(vec![ + unity_string_operation(None, "first", 0), + unity_string_operation(None, "first", 1), + ]); assert!(validate_patch_manifest(&manifest).is_err()); } + #[test] + fn validate_patch_manifest_rejects_whole_object_and_field_overlap() { + let manifest = unity_manifest(vec![ + unity_text_asset_operation(None, 0), + unity_string_operation(None, "first", 1), + ]); + + assert!(validate_patch_manifest(&manifest).is_err()); + } + + #[test] + fn validate_patch_manifest_rejects_parent_child_field_overlap() { + let manifest = unity_manifest(vec![ + unity_string_operation(None, "root", 0), + unity_field_operation(None, "root.child", 1), + ]); + + assert!(validate_patch_manifest(&manifest).is_err()); + } + + #[test] + fn validate_patch_manifest_applies_the_same_identity_rules_inside_zip_entries() { + validate_patch_manifest(&unity_manifest(vec![ + unity_string_operation(Some("bundles/one.bundle"), "first", 0), + unity_field_operation(Some("bundles/one.bundle"), "second", 1), + ])) + .unwrap(); + + for operations in [ + vec![ + unity_string_operation(Some("bundles/one.bundle"), "first", 0), + unity_string_operation(Some("bundles/one.bundle"), "first", 1), + ], + vec![ + unity_text_asset_operation(Some("bundles/one.bundle"), 0), + unity_string_operation(Some("bundles/one.bundle"), "first", 1), + ], + vec![ + unity_string_operation(Some("bundles/one.bundle"), "root", 0), + unity_field_operation(Some("bundles/one.bundle"), "root.child", 1), + ], + ] { + assert!(validate_patch_manifest(&unity_manifest(operations)).is_err()); + } + + validate_patch_manifest(&unity_manifest(vec![ + unity_string_operation(Some("bundles/one.bundle"), "first", 0), + unity_string_operation(Some("bundles/two.bundle"), "first", 1), + ])) + .unwrap(); + } + + fn unity_manifest(operations: Vec) -> PatchManifest { + let mut manifest = manifest_for("bundle", b"source", b"target"); + manifest.files[0].patch_kind = PatchKind::Mixed; + manifest.files[0].operations = operations; + manifest + } + + fn unity_string_operation( + archive_entry: Option<&str>, + field_path: &str, + sequence: u32, + ) -> PatchManifestOperation { + PatchManifestOperation { + sequence, + source_blake3: Some(blake3_hex(b"source")), + source_size: Some(6), + archive_entry: archive_entry.map(str::to_string), + payload: PatchManifestOperationPayload::UnityFsStringField { + serialized_file_path: "CAB-one".to_string(), + path_id: 7, + field_path: field_path.to_string(), + expected_value: None, + replacement: "replacement".to_string(), + }, + provenance: None, + } + } + + fn unity_field_operation( + archive_entry: Option<&str>, + field_path: &str, + sequence: u32, + ) -> PatchManifestOperation { + PatchManifestOperation { + sequence, + source_blake3: Some(blake3_hex(b"source")), + source_size: Some(6), + archive_entry: archive_entry.map(str::to_string), + payload: PatchManifestOperationPayload::UnityFsField { + serialized_file_path: "CAB-one".to_string(), + path_id: 7, + field_path: field_path.to_string(), + expected_value: None, + replacement: serde_json::json!({"kind": "string", "value": "replacement"}), + }, + provenance: None, + } + } + + fn unity_text_asset_operation( + archive_entry: Option<&str>, + sequence: u32, + ) -> PatchManifestOperation { + PatchManifestOperation { + sequence, + source_blake3: Some(blake3_hex(b"source")), + source_size: Some(6), + archive_entry: archive_entry.map(str::to_string), + payload: PatchManifestOperationPayload::UnityFsTextAsset { + serialized_file_path: "CAB-one".to_string(), + path_id: 7, + expected_name: None, + replacement: b"replacement".to_vec(), + }, + provenance: None, + } + } + fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest { PatchManifest { version: PATCH_MANIFEST_VERSION, diff --git a/docs/architecture/assetbundle.md b/docs/architecture/assetbundle.md index 53e70e0..5c63951 100644 --- a/docs/architecture/assetbundle.md +++ b/docs/architecture/assetbundle.md @@ -156,7 +156,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整 1. 官方同步完成后可配置触发导入 CAS + ResourceRepository(已具备 `--import-repository` / `BAT_IMPORT_REPOSITORY=1`)。 2. ResourceRepository 已保存官方 manifest 资源的类型、路径、hash、size 和 metadata;metadata 包含 release、平台、bundle path、parse status、TextAsset 名称、TextUnit 数量/格式。 -3. 支持 RPC/CLI 查询资源、bundle、TextAsset、解析错误和缓存状态;当前 `resource.index` 会返回资源 metadata,`parse-status` 会返回 TextUnit 索引和队列摘要,`parse-text-units` / `parse-errors` 会按当前 release 查询明细,`localized-status` 会校验 patch manifest。 +3. 支持 RPC/CLI 查询资源、bundle、TextAsset、解析错误和缓存状态;当前 `resource.index` 会返回资源 metadata,`parse-status` 会返回 TextUnit 索引和队列摘要,`parse-text-units` / `parse-errors` 会按当前 release 查询明细,`localized-status` 会区分 patch manifest contract 与 artifact integrity,`release.status/list/distribution/cleanup` 提供 Rust-owned 双 release 视图和安全运维操作。 4. schema 迁移可重复执行;当前 SQLite 已有 `crc` 和 `metadata_json` 兼容迁移。 验收: @@ -175,7 +175,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整 2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作;普通 ZIP 条目在 `archive_entry` 可验证、内层可重解析时会解包、重建并重写外层 ZIP,路径穿越、symlink、混合直接/ZIP patch 和无效内层 bundle 明确失败。 3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果;generic manifest 不把 UnityFS 定位信息扁平化。 4. Patch 产物写入配置化汉化发布根下的 `.staging/`,校验通过后发布到 `versions/` 并切换 `current`;rollback 按 manifest 恢复上一 release。 -5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release,ZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验。 +5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release,并分别报告 schema/contract 与 artifact integrity,ZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验;current 仍存在但产物损坏时返回 `localized.degraded`,不自动修复。 验收: diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 8148255..cdf06a7 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`,表示原版资源已发布、汉化资源未发布;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 的发布状态。 +16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;generic manifest 驱动的 Binary/JSON/Text 以及当前支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 及 ZIP 内层最终重解析校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`localized.status` 分开返回 `patch_manifest_contract_status` 与 `artifact_integrity_status`;state/current/identity 存在但文件被截断或手工修改时返回 `localized.degraded`,只读检查不回滚、不删除、不修复。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。 维护期特殊分支:如果官方 launcher/server-info 已经指向新资源根,但 client-patch seed marker 或必需 seed catalog 仍返回 403/404 等未开放状态,`bat` 返回 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging;若本轮启用 `--auto-discover`,会在 `/official-launcher-bootstrap.pending.json` 写入待处理 launcher bootstrap 证据,供后续排障和自研客户端开发使用。 @@ -345,8 +345,9 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边 - 方法命名空间与实现状态、请求/响应示例见 `docs/reference/rpc-backend-api.md`:`daemon.status/logs/stop/restart/reload/refresh/doctor`、 `resource.state/sync/verify/repair/manifest/list/index`、`parse.status/text_units/errors`、 - `translation.tasks/handoff/task.update/proofread`、`localized.status`、`catalog.*` 与 - `task.status/list/cancel/logs` 已实现;文件级 `patch.apply` / `unityfs.patch_*` + `translation.tasks/handoff/task.update/proofread`、`localized.status`、 + `release.status/list/distribution/cleanup`、`catalog.*` 与 `task.status/list/cancel/logs` + 已实现;文件级 `patch.apply` / `unityfs.patch_*` 和受支持 localized publish/rollback 已实现,`archive_entry` 可验证时会重写 外层 ZIP;通用发布级 patch 与复杂 UnityFS 语义编辑仍待后续; `task.create` 按设计暂不开放通用任务入口; @@ -357,7 +358,7 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边 - Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 是资源读侧、 bootstrap 和 HTTP 分发入口。二者之间的稳定边界是 `bat.sock` RPC 和 - `resource_root` 中已发布的只读文件。 + Rust 选择后返回的 `resource_root` 中已发布的只读文件。 - Go 层负责:资源 bootstrap、资源内容分发(`cmd/bat-api`)、HTTP API 进程配置、 以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon(连接 `bat.sock`,每行一个 JSON-RPC 请求/响应)。`cmd/bat` 仍是试验骨架,不是产品级用户 CLI。 @@ -371,6 +372,11 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边 manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。 - 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。 + - `/v1/releases`、`/v1/distribution` 和受保护的 `/admin/releases/status`、 + `/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed + 结果;localized 或历史分发不会绕过 Rust 完整性判断。 + - 受保护的 `/admin/control/release-cleanup` 只转发 Rust `release.cleanup`; + 先 dry-run 获取 `plan_id`,执行时由 Rust 重验证引用、路径和 current 保护。 - 支持 `.env` / 环境变量配置监听端口、public base URL、RPC socket 和 RPC 刷新周期,并预留 database/redis 键供后续 API 持久化;**不**负责资源自动拉取。 - 可选改写 server-info 中的 `AddressablesCatalogUrlRoot` 指向自身;不伪装 diff --git a/docs/architecture/resource-release-layout.md b/docs/architecture/resource-release-layout.md index ee6e393..a043c7e 100644 --- a/docs/architecture/resource-release-layout.md +++ b/docs/architecture/resource-release-layout.md @@ -8,6 +8,7 @@ - inventory 抽取:`adapters/src/official/inventory.rs` - 落盘与 manifest:`infrastructure/src/official_download.rs`(`destination_for_url`) - 发布布局:`infrastructure/src/official_update.rs` + - 双 release 视图、分发选择与清理:`infrastructure/src/release_ops.rs` - 分发:`cmd/bat-api` + `internal/api`(见 `docs/reports/GO_STATUS.md`) --- @@ -17,7 +18,7 @@ | 角色 | 组件 | 职责 | |---|---|---| | 同步 / 运维(近乎全自动) | Rust `bat` | auto-discover、拉取、校验、发布、watch/daemon、RPC 后端 | -| 资源 bootstrap / 只读分发 | Go `bat-api` | 同环境经 `bat.sock` 发现已发布版本和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写和官方 CDN path 字节 | +| 资源 bootstrap / 只读分发 | Go `bat-api` | 同环境经 `bat.sock` 读取 Rust 选择的已验证版本和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、官方/localized CDN path 字节和 release 管理转发 | | 试验 CLI | Go `cmd/bat` → `bin/bat-go` | 非产品;禁止与 Rust `bat` 重名 | **禁止**:把已安装客户端目录或 `/home/wanye/D/BlueArchive` 当作生产输入;真实全量样本优先服务器 release 或 `/tmp` 隔离目录。 @@ -85,13 +86,34 @@ - `not_localized`:官方原版资源已经完成下载、校验和发布,汉化资源尚未发布;这是官方同步完成后的默认状态。 - `localized`:同一官方版本的原版资源和汉化资源都已发布,生产侧可以同时提供两套资源。 -### 2.1 读侧 vs 写侧 +### 2.1 双 release 读写边界 + +Rust `bat` 的 `release.status` 是 official/localized 的统一只读视图,基于既有 +version state、current symlink、release manifest、文件系统和必要的 CAS/reference +元数据计算,不建立第二个 release 数据库。`release.list` 返回两个 namespace 的当前 +与历史 release,包含稳定 ID、created/published、source official relation、生命周期、 +`rollback_available`、manifest contract、artifact/distribution integrity、 +`stale`/`damaged`/`referenced`/`unknown`、rollback previous 和诊断;缺少 generic manifest 的 +旧 localized release 保留为 `legacy`/`unknown`,不自动改写。 + +`release.distribution` 的默认 channel 是 `official`。只有当前或显式历史、路径归属安全、 +source relation 正确且 manifest/artifact integrity 通过的 release 才能被选择;staging、 +损坏、缺失、symlink/path escape 或未验证历史项不会回退到另一 channel。返回的 +`resource_root` 和 manifest entry 由 Rust 决定,Go 只做 typed forwarding。 + +`release.cleanup` 先生成 dry-run 计划和 `plan_id`,执行时重新计算并比对计划。current、 +rollback previous、active/in-progress、localized source official、state/manifest/CAS +reference、无法确认 ownership 的对象均保留;只删除重新验证后仍为普通目录且确定无引用的 +历史 release。它不改变 current,不执行 rollback,也不负责自动 repair;staging 默认保留 +以避免删除未持久化任务。 + +### 2.2 读侧 vs 写侧 | 阶段 | 根目录 | |---|---| | 下载写入 | `/.staging/` | | 发布完成 | rename 到 `versions/`,再切换 `current` | -| 生产读取 / bat-api | RPC 给出的 `version.resource_root`;通常等价于 `current` 解析后的 versioned 目录 | +| 生产读取 / bat-api | RPC 给出的已验证 `resource_root`;默认等价于 official `current` 解析后的 versioned 目录,localized 必须显式选择 | 每个 release 的 `official-download-manifest.json` 是历史复用的索引。新 staging 按规范化 destination 查找候选,并重新验证 manifest 中的 size、BLAKE3 和 ZIP @@ -138,7 +160,10 @@ GET {public-base-url}/prod-clientpatch.bluearchiveyostar.com//... ≡ 磁盘 /prod-clientpatch.bluearchiveyostar.com//... ``` -默认仅服务 **download manifest 索引内且 Present + size 匹配** 的文件。 +默认仅服务 **official download manifest 索引内且 Present + size 匹配** 的文件。需要 +localized 或历史 release 时,调用 `release.distribution` 选择 Rust 已验证的 +`resource_root`,再由 `/v1/distribution` 或带 `channel`/`release_id` 的 CDN path +转发;Go 不在本地判断健康度,也不回退到 official。 ### 3.3 launcher 资源引导兼容 diff --git a/docs/guides/bat-workflows.md b/docs/guides/bat-workflows.md index ebe4b1b..65729c3 100644 --- a/docs/guides/bat-workflows.md +++ b/docs/guides/bat-workflows.md @@ -34,6 +34,42 @@ bat res pull --auto-discover --watch --interval 1h \ 资源下载默认使用 8 个独立 worker,允许范围为 `1..=256`。worker 完成当前 URL 后立即领取共享队列中的下一个任务,进度按完成顺序统计,最终报告仍按计划顺序输出。 +## Release 查询与清理 + +双 release 运维由 Rust `bat` 通过 `bat.sock` 提供,不新增平行顶层 CLI: + +```bash +# 查看 official/localized 当前、历史、source relation 和完整性 +printf '{"jsonrpc":"2.0","id":1,"method":"release.status"}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-state/bat.sock +printf '{"jsonrpc":"2.0","id":2,"method":"release.list","params":{"channel":"localized"}}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-state/bat.sock + +# 选择已验证的 localized 当前 release,默认 channel 仍是 official +printf '{"jsonrpc":"2.0","id":3,"method":"release.distribution","params":{"channel":"localized"}}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-state/bat.sock +``` + +`release.distribution` 只返回 Rust 已验证的当前或显式历史 release。localized 必须 +同时满足 source official、current、manifest identity、source/target hash/size 和 +UnityFS/ZIP 最终语义校验;staging、损坏、缺失或路径不安全的 release 不会跨 channel +fallback。`localized.status` 的 `patch_manifest_contract_status` 与 +`artifact_integrity_status` 分开表示 schema 和产物健康度,产物损坏时为 +`localized.degraded`,检查不会自动修复。 + +清理必须先 dry-run,再使用同一 `plan_id` 执行: + +```bash +printf '{"jsonrpc":"2.0","id":4,"method":"release.cleanup","params":{"execute":false}}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-state/bat.sock +printf '{"jsonrpc":"2.0","id":5,"method":"release.cleanup","params":{"execute":true,"plan_id":""}}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-state/bat.sock +``` + +Rust 会在执行前重算计划,保护 current、rollback previous、active/in-progress、 +localized source official、state/manifest/CAS/reference 和未知归属对象。cleanup 不改变 +current、不执行 rollback,也不删除 staging;回滚仍使用独立的 `localized.rollback`。 + ## 解析与重打包 解析当前已发布 release: diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index 959f83e..948e7c4 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -172,6 +172,23 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且 删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。 +### release + +| 方法 | 状态 | params | data | +|---|---|---|---| +| `release.status` | 已实现 | `null` | official/localized current、source relation、match、历史 release 和 manifest/artifact/distribution integrity 统一视图。 | +| `release.list` | 已实现 | `{ "channel": "official" }` 或 `{ "channel": "localized" }`,可省略 | 对应 namespace 的历史 release 摘要,包含 stable ID、created/published、current pointer、`rollback_available`、lifecycle、`stale`/`damaged`/`referenced`/`unknown`、legacy 和诊断。 | +| `release.distribution` | 已实现 | `{ "channel": "official", "release_id": "...", "offset": 0, "limit": 1000 }`,均可省略 | Rust 选择的 verified `resource_root` 和 download manifest entries;默认 channel 为 official,选择失败返回 `available=false`,不跨 channel fallback。 | +| `release.cleanup` | 已实现 | dry-run `{ "execute": false }`;执行 `{ "execute": true, "plan_id": "..." }` | cleanup plan、candidate/retain reasons、blocking references 和 removed paths;执行前会重新生成并比对 `plan_id`。 | + +`release.status`、`release.list` 和 `release.distribution` 只读现有 official/localized +state、current、manifest、文件系统和 CAS/reference 元数据,不创建第二套 release 状态。 +localized 只有在 source official、current pointer、manifest identity、source/target +hash/size 以及 UnityFS/ZIP 最终语义校验全部通过时才可分发;默认官方分发行为不变。 +`release.cleanup` 只删除 Rust 能证明是普通目录且未被 current、rollback、staging、 +source、state、manifest、CAS 或未知 ownership 引用的历史项,不修改 current,也不承担 +rollback 或 repair。 + ### schedule 调度计划由 Rust `bat` 持有,状态文件为 daemon `state_dir` 下的 @@ -345,7 +362,7 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t `./bat-localized` 混用。当前支持未汉化发布状态和已汉化发布状态的只读报告。 `status` / `status_code` 使用生命周期短状态和稳定状态码,例如 `pending` / `localized.pending`、`stale` / `localized.stale`、`published` / -`localized.published`;旧的 `localized` / `not_localized` 业务标签放在 +`localized.published`、`localized.degraded`;旧的 `localized` / `not_localized` 业务标签放在 `localized_release_status`。`translation_workflow_status` / `translation_workflow_status_code` 用于表示汉化工作流的人工校对状态,例如 `manual_proofreading` / `translation.manual_proofreading`。返回 `localized_release_status=localized` 的条件是: @@ -353,7 +370,10 @@ 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_manifest_integrity_status`、 +`patch_manifest_matches_release`、`patch_manifest_contract_status`、 +`patch_manifest_integrity_status`(兼容别名)、`artifact_integrity_status`、 +`artifact_integrity_verified`、`artifact_integrity_error` 和 +`artifact_integrity_diagnostics`、 `patch_manifest_source_version`、`patch_manifest_target_version`、 `patch_file_count`、`patch_operation_count`、`patch_kind_counts`、 `patch_text_asset_operation_count` 和 `rollback_previous_current_target`。 @@ -525,7 +545,7 @@ CLI 对应关系: envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。 - typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、 `resource.state/sync/verify/repair/manifest/list`、`schedule.list/add/update/remove/run`、 - `catalog.*`、`parse.*`、 + `catalog.*`、`parse.*`、`release.status/list/distribution/cleanup`、 `localized.status`、`localized.publish`、`localized.rollback`、 `translation.tasks`、`translation.handoff`、`translation.task.update`、 `translation.worker.run`、`translation.proofread`、`translation.memory.summary`、 @@ -551,6 +571,7 @@ CLI 对应关系: | `TranslationMemoryBackend` | `translation.memory.summary`、`translation.memory.query`、`translation.memory.confirm` | 鉴权后的 TM 摘要、source/context 查询和显式 candidate 确认;Go 只转发,不持有 TM 状态 | | `GlossaryBackend` | `translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` | 鉴权后的 Glossary 摘要、term/history 查询、确定性诊断和审核/删除 mutation;Go 只转发,不持有 Glossary 状态 | | `LocalizedBackend` | `localized.status`、`localized.publish`、`localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 | +| `ReleaseBackend` | `release.status`、`release.list`、`release.distribution`、`release.cleanup` | 鉴权后的双 release 查询、验证分发选择和 dry-run/execute cleanup;Go 不持有 release 状态 | `daemon.stop`、`daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。 Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 79900e3..e0fb437 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -32,7 +32,7 @@ directory 形态和未修改对象/字段,并明确拒绝未知压缩或无法 回归和 `bat-infrastructure` 的解析缓存、ZIP 内 bundle 发布测试。新增格式覆盖必须 同时补真实 fixture、回归测试和文档。 -### G-006:通用 Patch 的复杂格式和运维视图仍未完成 +### G-006:通用 Patch 的复杂格式和运维扩展仍未完成 状态:**V1 已完成(当前支持类型),复杂格式和运维扩展继续推进** @@ -41,13 +41,16 @@ rollback 元数据;`LocalizedPatchService` 已使用同一有序 generic manif 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 均已接入。 +provenance;UnityFS 目标身份至少包含 archive entry、serialized file、path ID 和实际 +field path,同对象 sibling field 可并存,重复、父子、whole-object/field 结构重叠会拒绝; +`localized.publish`、`i18n publish` 和 bat-api typed forwarding 均已接入。 仍需完成: - 任意复杂 AssetBundle 重打包和完整翻译文件集合构建;当前 localized publish 已支持 可验证 ZIP 内 bundle 的外层 ZIP 重写,但不扩大 UnityFS 结构支持范围。 -- 原版 release 与 localized release 双发布后的更丰富查询、分发和清理策略。 +- generic manifest 已冻结为当前支持类型的 V1;复杂 AssetBundle 结构仍需真实样本驱动, + 不在本项中扩展 Patch 格式。 所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变 已发布的 `bat-resources/current` 或 `bat-localized/current`。 @@ -106,14 +109,21 @@ format 等资源级过滤,`parse.text_units` / `parse.errors` 和翻译任务 - 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。 - 更多 schema 迁移、权限、并发和损坏恢复场景验证。 -### G-011D:双 release 的完整查询与运维策略仍未完成 +### G-011D:双 release 查询、分发与安全清理 -状态:**发布闭环完成,查询/清理扩展继续推进** +状态:**V1 已完成** 官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和 -rollback,`localized.status` 能校验当前官方 release 与 patch manifest 的一致性。 +rollback。Rust `release.status` 提供统一 current/source/match、manifest contract、 +artifact/distribution integrity、历史 release、legacy/stale/damaged 摘要; +`release.list` 查询两个 namespace,`release.distribution` 只允许当前或显式历史且已 +验证的 official/localized release,默认仍为 official;`release.cleanup` 提供 dry-run +`plan_id` 和执行前重验证,只删除确定未被 current、rollback、staging、source、状态、 +manifest、CAS 或未知归属引用的普通目录。`localized.status` 还区分 schema/contract 与 +artifact integrity,损坏产物返回 degraded/corrupt,不自动回滚或删除。 -仍需完成复杂重打包、双 release 查询/分发视图和清理策略。 +rollback 与 cleanup 保持独立;缺少 generic manifest 的旧 localized release 仍可读, +明确标记 `legacy`/`unknown`,不会被自动重写。 ### G-012:Translation Memory V1 已实现,扩展能力仍缺失 @@ -148,7 +158,8 @@ Rust `bat` 已提供独立项目级 `glossary.sqlite`:term/alias/recommended/a ## 3. 后续推进顺序 1. 继续 G-005:更多真实 AssetBundle 样本、复杂字段解析、版本差异和任意结构重打包。 -2. 继续 G-006/G-011D:复杂 AssetBundle 兼容和双 release 查询/清理策略。 +2. 继续 G-006:复杂 AssetBundle 兼容和真实样本覆盖;G-011D 的双 release 运维 V1 + 已完成,后续 retention scheduler 不属于本次闭环。 3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider 扩展体系。 4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。 diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index cf4dcaf..7e051be 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -95,8 +95,8 @@ | 组件 | 路径 | 状态 | 说明 | |---|---|---|---| | Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 | -| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`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` | +| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`release.status/list/distribution/cleanup`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`localized.publish` 的 typed params 支持 `translation_file`、`from_worker` 或 `patch_manifest` 三选一;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 | +| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + `/v1/releases` + `/v1/distribution` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + release/localized/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` | | 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README | @@ -134,7 +134,7 @@ make build-go-cli # 产出 bin/bat-go | 项 | 状态 | |---|---| | Go 同步 CLI | **边界已确定**(正式同步 CLI = Rust `bat`) | -| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*`、`task.*`、`parse.*`、翻译任务/TM 状态查询与显式确认代理、内嵌 dashboard、同机 live smoke 和部署模板;持久化仍另议 | +| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*`、`task.*`、`parse.*`、`release.*` 双 release 查询/分发/cleanup 转发、翻译任务/TM 状态查询与显式确认代理、内嵌 dashboard、同机 live smoke 和部署模板;持久化仍另议 | | G-010 Web | 内嵌 dashboard MVP 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 | --- diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index d7eaab5..95a1763 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -7,30 +7,33 @@ use bat_core::{ApiError, ErrorCode}; use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY; use bat_infrastructure::{ apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, - apply_unityfs_text_asset_patch_file, changed_endpoint_urls, - completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench, - gc_orphan_staging_with_cas_root, get_translation_entry, lexical_absolute, + apply_unityfs_text_asset_patch_file, build_release_list, build_release_status, + changed_endpoint_urls, cleanup_releases, completed_worker_translation_workbench, + diff_extended_snapshot, export_translation_workbench, gc_orphan_staging_with_cas_root, + get_translation_entry, inspect_localized_release_artifact, lexical_absolute, localized_patch_operations_with_glossary_path, open_append_file, read_download_manifest_at, read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench, - read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, - set_translation_checked_with_glossary_path, unset_translation, validate_output_root, - validate_runtime_state_dir, validate_translation_workbench_with_glossary_path, - write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, - LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport, + read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, + select_release_distribution, set_translation, set_translation_checked_with_glossary_path, + unset_translation, validate_output_root, validate_runtime_state_dir, + validate_translation_workbench_with_glossary_path, write_file_atomic, + write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig, + LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport, OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService, OfficialParseConfig, OfficialResourceHashVerification, OfficialResourceVerification, OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams, - PatchApplyReport, ReleaseFlowStatusCode, RepackReport, SqliteResourceRepository, - SqliteTranslationTaskRepository, TranslationProviderKind, TranslationTaskStatus, - TranslationWorkerConfig, UnityFsFieldPatchParams, UnityFsPatchReport, - UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE, - DEFAULT_TRANSLATION_CONCURRENCY, DEFAULT_TRANSLATION_LEASE_SECONDS, - DEFAULT_TRANSLATION_MAX_ATTEMPTS, DEFAULT_TRANSLATION_RETRY_BACKOFF, LOCALIZED_CURRENT_LINK, - LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING, + PatchApplyReport, ReleaseCleanupParams, ReleaseDistributionParams, ReleaseFlowStatusCode, + ReleaseListParams, RepackReport, SqliteResourceRepository, SqliteTranslationTaskRepository, + TranslationProviderKind, TranslationTaskStatus, TranslationWorkerConfig, + UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams, + UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE, DEFAULT_TRANSLATION_CONCURRENCY, + DEFAULT_TRANSLATION_LEASE_SECONDS, DEFAULT_TRANSLATION_MAX_ATTEMPTS, + DEFAULT_TRANSLATION_RETRY_BACKOFF, LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, + LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, MAX_TRANSLATION_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY, MIN_TRANSLATION_CONCURRENCY, OFFICIAL_PARSE_CACHE_FILE, @@ -1195,6 +1198,10 @@ const RPC_METHOD_GLOSSARY_DIAGNOSE: &str = "translation.glossary.diagnose"; const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status"; const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish"; const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback"; +const RPC_METHOD_RELEASE_STATUS: &str = "release.status"; +const RPC_METHOD_RELEASE_LIST: &str = "release.list"; +const RPC_METHOD_RELEASE_DISTRIBUTION: &str = "release.distribution"; +const RPC_METHOD_RELEASE_CLEANUP: &str = "release.cleanup"; const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status"; const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions"; const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff"; @@ -2371,6 +2378,94 @@ fn dispatch_rpc_method( Err(error) => rpc_envelope_error(request_id, error), } } + RPC_METHOD_RELEASE_STATUS => { + let _sync_guard = tasks + .sync_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + rpc_envelope_from_result( + request_id, + RPC_METHOD_RELEASE_STATUS, + build_release_status( + &tasks.base_config.output_root, + &tasks.base_config.localized_output_root, + &tasks.base_config.unzip_command, + ) + .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), + ) + } + RPC_METHOD_RELEASE_LIST => { + let params = match rpc_optional_struct_params::( + request.params.as_ref(), + RPC_METHOD_RELEASE_LIST, + ) { + Ok(params) => params, + Err(error) => return rpc_envelope_error(request_id, error), + }; + let _sync_guard = tasks + .sync_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + rpc_envelope_from_result( + request_id, + RPC_METHOD_RELEASE_LIST, + build_release_list( + &tasks.base_config.output_root, + &tasks.base_config.localized_output_root, + ¶ms, + &tasks.base_config.unzip_command, + ), + ) + } + RPC_METHOD_RELEASE_DISTRIBUTION => { + let params = match rpc_optional_struct_params::( + request.params.as_ref(), + RPC_METHOD_RELEASE_DISTRIBUTION, + ) { + Ok(params) => params, + Err(error) => return rpc_envelope_error(request_id, error), + }; + let _sync_guard = tasks + .sync_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + rpc_envelope_from_result( + request_id, + RPC_METHOD_RELEASE_DISTRIBUTION, + select_release_distribution( + &tasks.base_config.output_root, + &tasks.base_config.localized_output_root, + ¶ms, + &tasks.base_config.unzip_command, + ) + .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), + ) + } + RPC_METHOD_RELEASE_CLEANUP => { + let params = match rpc_optional_struct_params::( + request.params.as_ref(), + RPC_METHOD_RELEASE_CLEANUP, + ) { + Ok(params) => params, + Err(error) => return rpc_envelope_error(request_id, error), + }; + let _sync_guard = tasks + .sync_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + rpc_envelope_from_result( + request_id, + RPC_METHOD_RELEASE_CLEANUP, + cleanup_releases( + &tasks.base_config.output_root, + &tasks.base_config.localized_output_root, + &tasks.base_config.effective_import_cas_root(), + ¶ms, + &tasks.base_config.unzip_command, + ) + .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), + ) + } RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result( request_id, "catalog.status", @@ -3281,7 +3376,12 @@ 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_contract_status: Option = None; + let mut patch_manifest_integrity_status: Option = None; + let mut artifact_integrity_status = None; + let mut artifact_integrity_verified = false; + let mut artifact_integrity_error = None; + let mut artifact_integrity_diagnostics = Vec::new(); let mut patch_manifest_source_version = None; let mut patch_manifest_target_version = None; let mut patch_file_count = None; @@ -3321,69 +3421,161 @@ fn build_localized_status_report( } if localized_state.status == "localized" && matches_current_official_release { if let Some(release_id) = localized_state.current_release_id.as_deref() { - let candidate = localized_root.join(LOCALIZED_VERSIONS_DIR).join(release_id); - current_points_to_published_version = - localized_current_points_to(¤t_path, &candidate); - let manifest_path = candidate.join(LOCALIZED_PATCH_MANIFEST_FILE); - patch_manifest_path = Some(manifest_path); - if let Some(manifest) = read_localized_patch_manifest_at(&candidate)? { - patch_manifest_available = true; - patch_manifest_matches_release = official_version_id - .as_deref() - .is_some_and(|id| manifest.official_release_id == id) - && manifest.localized_release_id == release_id; - 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" + let official_id_is_safe = official_version_id + .as_deref() + .is_some_and(is_safe_release_id); + if !is_safe_release_id(release_id) || !official_id_is_safe { + let diagnostic = if !is_safe_release_id(release_id) { + format!("localized current release identity 不安全:{release_id}") + } else { + format!( + "official current release identity 不安全:{}", + official_version_id.as_deref().unwrap_or_default() + ) + }; + localized_release_status = "degraded"; + flow_status_code = ReleaseFlowStatusCode::LocalizedDegraded; + patch_manifest_contract_status = Some("invalid".to_string()); + patch_manifest_integrity_status = Some("invalid".to_string()); + artifact_integrity_status = Some("invalid".to_string()); + artifact_integrity_error = Some(diagnostic.clone()); + artifact_integrity_diagnostics.push(diagnostic); + } else { + let candidate = localized_root.join(LOCALIZED_VERSIONS_DIR).join(release_id); + current_points_to_published_version = + localized_current_points_to(¤t_path, &candidate); + let manifest_path = candidate.join(LOCALIZED_PATCH_MANIFEST_FILE); + patch_manifest_path = Some(manifest_path.clone()); + match read_localized_patch_manifest_at(&candidate) { + Ok(Some(manifest)) => { + patch_manifest_available = true; + patch_manifest_matches_release = official_version_id + .as_deref() + .is_some_and(|id| manifest.official_release_id == id) + && manifest.localized_release_id == release_id; + 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 + ); } - 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_contract_status = Some( + if bat_patch::validate_patch_manifest(generic_manifest).is_ok() + { + "valid" + } else { + "invalid" + } + .to_string(), + ); + patch_manifest_integrity_status = + patch_manifest_contract_status.clone(); + patch_manifest_matches_release &= generic_manifest.source_version + == manifest.official_release_id + && generic_manifest.target_version + == manifest.localized_release_id; + } else { + patch_manifest_contract_status = Some("legacy".to_string()); + patch_manifest_integrity_status = Some("legacy".to_string()); } } - 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"); + Ok(None) => { + patch_manifest_contract_status = Some("missing".to_string()); + patch_manifest_integrity_status = Some("missing".to_string()); + } + Err(error) => { + patch_manifest_available = fs::symlink_metadata(&manifest_path).is_ok(); + patch_manifest_contract_status = Some("invalid".to_string()); + patch_manifest_integrity_status = Some("invalid".to_string()); + artifact_integrity_error = Some(error.to_string()); + artifact_integrity_diagnostics.push(error.to_string()); + } + } + if let Some(official_root) = status_file + .as_ref() + .map(|status| { + status + .resource_output_root + .join("versions") + .join(official_version_id.as_deref().unwrap_or_default()) + }) + .or_else(|| { + Some( + base_config + .output_root + .join("versions") + .join(official_version_id.as_deref().unwrap_or_default()), + ) + }) + { + let artifact = inspect_localized_release_artifact( + &official_root, + &localized_root, + release_id, + official_version_id.as_deref().unwrap_or_default(), + &base_config.unzip_command, + ); + artifact_integrity_status = + Some(artifact.artifact_integrity_status.clone()); + artifact_integrity_verified = artifact.verified; + artifact_integrity_error = artifact.error.clone(); + artifact_integrity_diagnostics = artifact.diagnostics.clone(); + current_points_to_published_version = artifact.current_points_to_release; + if artifact.manifest_available { + patch_manifest_available = true; + } + if !artifact.manifest_contract_status.is_empty() { + patch_manifest_contract_status = + Some(artifact.manifest_contract_status); + patch_manifest_integrity_status = + patch_manifest_contract_status.clone(); + } + patch_manifest_matches_release = artifact.manifest_matches_release; + } + if candidate.is_dir() + && current_points_to_published_version + && patch_manifest_matches_release + && artifact_integrity_verified + { + localized_release_status = "localized"; + flow_status_code = ReleaseFlowStatusCode::LocalizedPublished; + published_version_path = Some(candidate); + } else if candidate.is_dir() && current_points_to_published_version { + localized_release_status = "degraded"; + flow_status_code = ReleaseFlowStatusCode::LocalizedDegraded; + published_version_path = Some(candidate); } - } - if candidate.is_dir() - && current_points_to_published_version - && patch_manifest_matches_release - { - localized_release_status = "localized"; - flow_status_code = ReleaseFlowStatusCode::LocalizedPublished; - published_version_path = Some(candidate); } } } @@ -3413,7 +3605,12 @@ 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_contract_status": patch_manifest_contract_status, "patch_manifest_integrity_status": patch_manifest_integrity_status, + "artifact_integrity_status": artifact_integrity_status, + "artifact_integrity_verified": artifact_integrity_verified, + "artifact_integrity_error": artifact_integrity_error, + "artifact_integrity_diagnostics": artifact_integrity_diagnostics, "patch_manifest_source_version": patch_manifest_source_version, "patch_manifest_target_version": patch_manifest_target_version, "patch_file_count": patch_file_count, @@ -3442,6 +3639,16 @@ fn localized_current_points_to(current_path: &Path, version_path: &Path) -> bool resolved == version_path } +fn is_safe_release_id(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && !value.contains('/') + && !value.contains('\\') + && !value.contains(':') + && !value.contains('\0') +} + #[cfg(unix)] fn write_json_rpc_response( stream: &mut UnixStream, diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index 4a2ea66..17cb171 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -4226,6 +4226,75 @@ fn dispatch_catalog_versions_lists_history() { assert!(value["data"]["failed"].as_array().unwrap().is_empty()); } +#[cfg(unix)] +#[test] +fn dispatch_release_rpc_exposes_dual_release_queries_and_safe_cleanup_plan() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + let localized_root = temp.path().join("localized"); + let tasks = test_task_context_with_config(OfficialUpdateConfig { + output_root: output_root.clone(), + localized_output_root: localized_root.clone(), + ..OfficialUpdateConfig::default() + }); + + let status = dispatch_rpc_method( + &rpc_request("release.status", None), + &state_dir, + &new_daemon_control(), + &tasks, + "req-release-status".to_string(), + ); + let status = serde_json::to_value(status).unwrap(); + assert_eq!(status["ok"], true); + assert_eq!(status["data"]["default_distribution_channel"], "official"); + assert_eq!(status["data"]["official_current_release_id"], "v-current"); + + let list = dispatch_rpc_method( + &rpc_request( + "release.list", + Some(serde_json::json!({"channel": "official"})), + ), + &state_dir, + &new_daemon_control(), + &tasks, + "req-release-list".to_string(), + ); + let list = serde_json::to_value(list).unwrap(); + assert_eq!(list["ok"], true); + assert_eq!(list["data"]["channel"], "official"); + assert_eq!(list["data"]["releases"][0]["channel"], "official"); + + let distribution = dispatch_rpc_method( + &rpc_request( + "release.distribution", + Some(serde_json::json!({"channel": "localized"})), + ), + &state_dir, + &new_daemon_control(), + &tasks, + "req-release-distribution".to_string(), + ); + let distribution = serde_json::to_value(distribution).unwrap(); + assert_eq!(distribution["ok"], true); + assert_eq!(distribution["data"]["available"], false); + assert_eq!(distribution["data"]["channel"], "localized"); + + let cleanup = dispatch_rpc_method( + &rpc_request("release.cleanup", None), + &state_dir, + &new_daemon_control(), + &tasks, + "req-release-cleanup".to_string(), + ); + let cleanup = serde_json::to_value(cleanup).unwrap(); + assert_eq!(cleanup["ok"], true); + assert_eq!(cleanup["data"]["execute"], false); + assert!(cleanup["data"]["plan_id"].as_str().is_some()); +} + #[test] fn dispatch_catalog_diff_reports_bundle_change() { let temp = tempfile::TempDir::new().unwrap(); @@ -5086,7 +5155,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let output_root = temp.path().join("output"); - write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + let official_version = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); let localized_root = temp.path().join("localized"); let localized_version = localized_root .join(LOCALIZED_VERSIONS_DIR) @@ -5097,6 +5166,8 @@ fn dispatch_localized_status_verifies_current_release_pointer() { localized_root.join(LOCALIZED_CURRENT_LINK), ) .unwrap(); + fs::write(official_version.join("data.bin"), b"official").unwrap(); + fs::write(localized_version.join("data.bin"), b"localized").unwrap(); fs::write( localized_root.join(LOCALIZED_VERSION_STATE_FILE), serde_json::to_vec(&bat_infrastructure::LocalizedVersionState { @@ -5117,9 +5188,18 @@ fn dispatch_localized_status_verifies_current_release_pointer() { official_release_id: "v-current".to_string(), localized_release_id: "v-current".to_string(), generated_unix_seconds: 124, - file_count: 0, + file_count: 1, text_asset_operation_count: 0, - files: Vec::new(), + files: vec![bat_infrastructure::LocalizedPatchFile { + path: "data.bin".to_string(), + original_blake3: blake3::hash(b"official").to_hex().to_string(), + localized_blake3: blake3::hash(b"localized").to_hex().to_string(), + original_bytes: 8, + localized_bytes: 9, + byte_delta: 1, + text_asset_operations: Vec::new(), + operations: Vec::new(), + }], patch_manifest: None, rollback: bat_infrastructure::LocalizedPatchRollbackInfo { previous_current_target: None, @@ -5152,7 +5232,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() { assert_eq!(value["data"]["current_points_to_published_version"], true); assert_eq!(value["data"]["patch_manifest_available"], true); assert_eq!(value["data"]["patch_manifest_matches_release"], true); - assert_eq!(value["data"]["patch_file_count"], 0); + assert_eq!(value["data"]["patch_file_count"], 1); assert_eq!(value["data"]["patch_text_asset_operation_count"], 0); assert_eq!( value["data"]["translation_workflow_status"], @@ -5162,6 +5242,23 @@ fn dispatch_localized_status_verifies_current_release_pointer() { value["data"]["published_version_path"].as_str().unwrap(), localized_version.to_string_lossy() ); + assert_eq!(value["data"]["patch_manifest_contract_status"], "legacy"); + assert_eq!(value["data"]["artifact_integrity_status"], "valid"); + + fs::write(localized_version.join("data.bin"), b"corrupt").unwrap(); + let envelope = dispatch_rpc_method( + &rpc_request("localized.status", None), + &state_dir, + &new_daemon_control(), + &tasks, + "req-loc-corrupt".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["status_code"], "localized.degraded"); + assert_eq!(value["data"]["localized_release_status"], "degraded"); + assert_eq!(value["data"]["artifact_integrity_status"], "invalid"); + assert_eq!(value["data"]["artifact_integrity_verified"], false); } #[test] diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 069deae..0fcd75c 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -29,6 +29,7 @@ pub mod official_update; pub mod patch_ops; pub mod path_security; pub mod release_flow; +pub mod release_ops; pub mod resources; pub mod translation_memory; pub mod translation_tasks; @@ -53,15 +54,16 @@ pub use import::{ ResourceImportService, }; pub use localized_patch::{ + inspect_localized_release_artifact, inspect_localized_release_artifact_at, mark_localized_manual_proofreading, read_localized_patch_manifest_at, - read_localized_version_state, write_localized_version_state, LocalizedFieldPatch, - LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput, LocalizedPatchIntegrity, - LocalizedPatchManifest, LocalizedPatchOperation, LocalizedPatchOperationMetadata, - LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService, - LocalizedRollbackReport, LocalizedStringFieldPatch, LocalizedTextAssetPatch, - LocalizedTranslationWorkflowReport, LocalizedVersionState, LOCALIZED_CURRENT_LINK, - LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR, - LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING, + read_localized_version_state, write_localized_version_state, LocalizedArtifactIntegrityReport, + LocalizedFieldPatch, LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput, + LocalizedPatchIntegrity, LocalizedPatchManifest, LocalizedPatchOperation, + LocalizedPatchOperationMetadata, LocalizedPatchReport, LocalizedPatchRollbackInfo, + LocalizedPatchService, LocalizedRollbackReport, LocalizedStringFieldPatch, + LocalizedTextAssetPatch, LocalizedTranslationWorkflowReport, LocalizedVersionState, + LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, + LOCALIZED_STAGING_DIR, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE, LOCALIZED_VERSION_STATE_VERSION, }; @@ -145,6 +147,11 @@ pub use path_security::{ validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE, }; pub use release_flow::ReleaseFlowStatusCode; +pub use release_ops::{ + build_release_list, build_release_status, cleanup_releases, select_release_distribution, + ReleaseCleanupParams, ReleaseCleanupReport, ReleaseDistributionEntry, ReleaseDistributionPage, + ReleaseDistributionParams, ReleaseListParams, ReleaseStatusReport, ReleaseSummary, +}; pub use resources::{InMemoryResourceRepository, SqliteResourceRepository}; pub use translation_memory::{ translation_memory_context, translation_memory_repository_path, diff --git a/infrastructure/src/localized_patch.rs b/infrastructure/src/localized_patch.rs index 56b8db1..13ef9dd 100644 --- a/infrastructure/src/localized_patch.rs +++ b/infrastructure/src/localized_patch.rs @@ -6,7 +6,7 @@ use bat_assetbundle::{ UnitySerializedValue, }; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -446,6 +446,30 @@ pub struct LocalizedPatchIntegrity { pub current_points_to_release: bool, } +/// Read-only contract and artifact verification result for one localized +/// release. Contract validity covers the manifest schema and release identity; +/// artifact integrity additionally verifies recorded files and UnityFS/ZIP +/// semantic replacements. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LocalizedArtifactIntegrityReport { + /// `valid`, `legacy`, `invalid` or `missing`. + pub manifest_contract_status: String, + /// `valid`, `invalid` or `unavailable`. + pub artifact_integrity_status: String, + /// Whether all read-only checks passed. + pub verified: bool, + /// Whether `current` points to this release. + pub current_points_to_release: bool, + /// Whether the manifest file could be read. + pub manifest_available: bool, + /// Whether manifest release IDs match the observed state. + pub manifest_matches_release: bool, + /// First diagnostic, retained for compact callers. + pub error: Option, + /// All diagnostics from the read-only inspection. + pub diagnostics: Vec, +} + /// Persisted manifest for one localized release. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalizedPatchManifest { @@ -1932,6 +1956,292 @@ fn verify_published_localized_release( Ok(integrity) } +/// Inspects one localized release without changing state, staging, current or +/// any repair target. +pub fn inspect_localized_release_artifact( + official_release_root: &Path, + localized_output_root: &Path, + localized_release_id: &str, + expected_official_release_id: &str, + unzip_command: &Path, +) -> LocalizedArtifactIntegrityReport { + inspect_localized_release_artifact_inner( + official_release_root, + localized_output_root, + localized_release_id, + expected_official_release_id, + unzip_command, + true, + ) +} + +/// Inspects a historical localized release without requiring the channel +/// `current` pointer to select it. +pub fn inspect_localized_release_artifact_at( + official_release_root: &Path, + localized_output_root: &Path, + localized_release_id: &str, + expected_official_release_id: &str, + unzip_command: &Path, +) -> LocalizedArtifactIntegrityReport { + inspect_localized_release_artifact_inner( + official_release_root, + localized_output_root, + localized_release_id, + expected_official_release_id, + unzip_command, + false, + ) +} + +fn is_safe_release_id(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && !value.contains('/') + && !value.contains('\\') + && !value.contains(':') + && !value.contains('\0') +} + +fn inspect_localized_release_artifact_inner( + official_release_root: &Path, + localized_output_root: &Path, + localized_release_id: &str, + expected_official_release_id: &str, + unzip_command: &Path, + require_current_pointer: bool, +) -> LocalizedArtifactIntegrityReport { + let mut diagnostics = Vec::new(); + if !is_safe_release_id(localized_release_id) { + diagnostics.push(format!( + "localized release identity 不安全:{localized_release_id}" + )); + } + if !is_safe_release_id(expected_official_release_id) { + diagnostics.push(format!( + "official release identity 不安全:{expected_official_release_id}" + )); + } + if !diagnostics.is_empty() { + return LocalizedArtifactIntegrityReport { + manifest_contract_status: "invalid".to_string(), + artifact_integrity_status: "invalid".to_string(), + verified: false, + current_points_to_release: false, + manifest_available: false, + manifest_matches_release: false, + error: diagnostics.first().cloned(), + diagnostics, + }; + } + let version_path = localized_output_root + .join(LOCALIZED_VERSIONS_DIR) + .join(localized_release_id); + let current_path = localized_output_root.join(LOCALIZED_CURRENT_LINK); + let current_points_to_release = + current_points_to_version(¤t_path, &version_path).unwrap_or(false); + let candidate_exists = fs::symlink_metadata(&version_path) + .map(|metadata| metadata.is_dir()) + .unwrap_or(false); + if !candidate_exists { + diagnostics.push(format!( + "localized release 目录不存在:{}", + version_path.display() + )); + return LocalizedArtifactIntegrityReport { + manifest_contract_status: "missing".to_string(), + artifact_integrity_status: "unavailable".to_string(), + verified: false, + current_points_to_release, + manifest_available: false, + manifest_matches_release: false, + error: diagnostics.first().cloned(), + diagnostics, + }; + } + if require_current_pointer && !current_points_to_release { + diagnostics.push(format!( + "localized current 未指向 release:current={} version={}", + current_path.display(), + version_path.display() + )); + } + if let Err(error) = ensure_safe_directory_path(&version_path, "汉化 release") { + diagnostics.push(error.to_string()); + } + + let manifest_available = fs::symlink_metadata(version_path.join(LOCALIZED_PATCH_MANIFEST_FILE)) + .map(|metadata| !metadata.file_type().is_symlink()) + .unwrap_or(false); + let manifest = match read_localized_patch_manifest_at(&version_path) { + Ok(Some(manifest)) => manifest, + Ok(None) => { + diagnostics.push(format!( + "缺少汉化 patch manifest:{}", + version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display() + )); + return LocalizedArtifactIntegrityReport { + manifest_contract_status: "missing".to_string(), + artifact_integrity_status: "invalid".to_string(), + verified: false, + current_points_to_release, + manifest_available, + manifest_matches_release: false, + error: diagnostics.first().cloned(), + diagnostics, + }; + } + Err(error) => { + diagnostics.push(error.to_string()); + return LocalizedArtifactIntegrityReport { + manifest_contract_status: "invalid".to_string(), + artifact_integrity_status: "invalid".to_string(), + verified: false, + current_points_to_release, + manifest_available, + manifest_matches_release: false, + error: diagnostics.first().cloned(), + diagnostics, + }; + } + }; + let wrapper_matches_release = manifest.official_release_id == expected_official_release_id + && manifest.localized_release_id == localized_release_id; + if !wrapper_matches_release { + diagnostics.push(format!( + "localized manifest identity 不匹配:official={} localized={}", + manifest.official_release_id, manifest.localized_release_id + )); + } + let generic_matches_wrapper = manifest.patch_manifest.as_ref().is_none_or(|generic| { + generic.source_version == manifest.official_release_id + && generic.target_version == manifest.localized_release_id + }); + if !generic_matches_wrapper { + diagnostics.push( + "generic manifest source_version/target_version 与 localized wrapper 不一致" + .to_string(), + ); + } + let manifest_matches_release = wrapper_matches_release && generic_matches_wrapper; + let manifest_contract_status = if let Some(generic) = manifest.patch_manifest.as_ref() { + match bat_patch::validate_patch_manifest(generic) { + Ok(()) if manifest_matches_release => "valid", + Ok(()) => "invalid", + Err(error) => { + diagnostics.push(format!("generic manifest schema 无效:{error}")); + "invalid" + } + } + } else { + "legacy" + }; + if manifest_contract_status == "invalid" { + return LocalizedArtifactIntegrityReport { + manifest_contract_status: manifest_contract_status.to_string(), + artifact_integrity_status: "invalid".to_string(), + verified: false, + current_points_to_release, + manifest_available, + manifest_matches_release, + error: diagnostics.first().cloned(), + diagnostics, + }; + } + if let Err(error) = verify_patch_manifest_files( + official_release_root, + &version_path, + &manifest, + unzip_command, + ) { + diagnostics.push(error.to_string()); + } + if manifest.patch_manifest.is_some() { + match crate::official_download::read_download_manifest_at(official_release_root) { + Ok(Some(download_manifest)) => { + if let Err(error) = verify_localized_resource_root( + official_release_root, + &version_path, + &manifest, + &download_manifest, + ) { + diagnostics.push(error.to_string()); + } + } + Ok(None) => diagnostics.push(format!( + "缺少官方 download manifest,无法完成 generic release 全量完整性检查:{}", + official_release_root.display() + )), + Err(error) => diagnostics.push(error), + } + } + let verified = diagnostics.is_empty() + && (!require_current_pointer || current_points_to_release) + && manifest_matches_release; + LocalizedArtifactIntegrityReport { + manifest_contract_status: manifest_contract_status.to_string(), + artifact_integrity_status: if verified { + "valid".to_string() + } else { + "invalid".to_string() + }, + verified, + current_points_to_release, + manifest_available, + manifest_matches_release, + error: diagnostics.first().cloned(), + diagnostics, + } +} + +fn verify_localized_resource_root( + official_release_root: &Path, + localized_release_root: &Path, + localized_manifest: &LocalizedPatchManifest, + download_manifest: &crate::official_download::OfficialDownloadManifest, +) -> anyhow::Result<()> { + let changed_paths = localized_manifest + .files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); + for entry in download_manifest.entries.values() { + let official_path = official_release_root.join(&entry.destination); + let localized_path = localized_release_root.join(&entry.destination); + ensure_path_within_root(official_release_root, &official_path) + .map_err(anyhow::Error::msg)?; + ensure_path_within_root(localized_release_root, &localized_path) + .map_err(anyhow::Error::msg)?; + ensure_safe_file_target(official_release_root, &official_path, "官方 release 文件") + .map_err(anyhow::Error::msg)?; + ensure_safe_file_target(localized_release_root, &localized_path, "汉化 release 文件") + .map_err(anyhow::Error::msg)?; + let official = fs::read(&official_path)?; + if official.len() as u64 != entry.bytes + || blake3::hash(&official).to_hex().to_string() != entry.blake3 + { + return Err(anyhow::anyhow!( + "官方 source 文件完整性失败:{}", + entry.destination + )); + } + let localized = fs::read(&localized_path)?; + if changed_paths.contains(entry.destination.as_str()) { + continue; + } + if localized.len() as u64 != entry.bytes + || blake3::hash(&localized).to_hex().to_string() != entry.blake3 + { + return Err(anyhow::anyhow!( + "汉化 release 未变更文件完整性失败:{}", + entry.destination + )); + } + } + Ok(()) +} + fn verify_patch_manifest_files( official_release_root: &Path, localized_release_root: &Path, @@ -2463,7 +2773,10 @@ fn release_id_from_current_target(target: &Path) -> Option { Some(std::path::Component::Normal(root)), Some(std::path::Component::Normal(release_id)), None, - ) if root == LOCALIZED_VERSIONS_DIR => release_id.to_str().map(str::to_string), + ) if root == LOCALIZED_VERSIONS_DIR => { + let release_id = release_id.to_str()?; + is_safe_release_id(release_id).then_some(release_id.to_string()) + } _ => None, } } @@ -2567,6 +2880,8 @@ fn validate_config(config: &LocalizedPatchConfig) -> Result<(), String> { if release_id.is_empty() || release_id.contains('/') || release_id.contains('\\') + || release_id.contains(':') + || release_id.contains('\0') || release_id == "." || release_id == ".." { @@ -3440,6 +3755,26 @@ mod tests { assert!(report.publish_allowed); } + #[test] + fn artifact_inspection_rejects_unsafe_release_identity_before_reading_paths() { + let temp = TempDir::new().unwrap(); + let report = inspect_localized_release_artifact_at( + &temp.path().join("official"), + &temp.path().join("localized"), + "../outside", + "official-1", + Path::new("unzip"), + ); + + assert_eq!(report.manifest_contract_status, "invalid"); + assert_eq!(report.artifact_integrity_status, "invalid"); + assert!(!report.verified); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("identity 不安全"))); + } + #[cfg(unix)] #[test] fn failed_patch_publish_cleans_staging_and_unpublished_version() { diff --git a/infrastructure/src/release_flow.rs b/infrastructure/src/release_flow.rs index 42af4a3..840ad53 100644 --- a/infrastructure/src/release_flow.rs +++ b/infrastructure/src/release_flow.rs @@ -78,6 +78,10 @@ pub enum ReleaseFlowStatusCode { /// A localized release is published and matches the current official release. #[serde(rename = "localized.published")] LocalizedPublished, + /// A localized current release exists but its manifest or published bytes + /// fail read-only integrity verification. + #[serde(rename = "localized.degraded")] + LocalizedDegraded, /// Distribution cannot serve a usable release for the observed channel. #[serde(rename = "distribution.blocked")] DistributionBlocked, @@ -113,6 +117,7 @@ impl ReleaseFlowStatusCode { Self::LocalizedPending => "localized.pending", Self::LocalizedStale => "localized.stale", Self::LocalizedPublished => "localized.published", + Self::LocalizedDegraded => "localized.degraded", Self::DistributionBlocked => "distribution.blocked", Self::DistributionReady => "distribution.ready", } @@ -146,6 +151,7 @@ impl ReleaseFlowStatusCode { Self::LocalizedPending => "pending", Self::LocalizedStale => "stale", Self::LocalizedPublished => "published", + Self::LocalizedDegraded => "degraded", Self::DistributionBlocked => "blocked", Self::DistributionReady => "ready", } @@ -177,6 +183,7 @@ impl ReleaseFlowStatusCode { "localized.pending" => Self::LocalizedPending, "localized.stale" => Self::LocalizedStale, "localized.published" => Self::LocalizedPublished, + "localized.degraded" => Self::LocalizedDegraded, "distribution.blocked" => Self::DistributionBlocked, "distribution.ready" => Self::DistributionReady, _ => return None, @@ -208,7 +215,8 @@ impl ReleaseFlowStatusCode { Self::LocalizedBlockedOfficial | Self::LocalizedPending | Self::LocalizedStale - | Self::LocalizedPublished => "localized_publish", + | Self::LocalizedPublished + | Self::LocalizedDegraded => "localized_publish", Self::DistributionBlocked | Self::DistributionReady => "distribution", } } @@ -300,6 +308,7 @@ mod tests { ReleaseFlowStatusCode::LocalizedPending, ReleaseFlowStatusCode::LocalizedStale, ReleaseFlowStatusCode::LocalizedPublished, + ReleaseFlowStatusCode::LocalizedDegraded, ReleaseFlowStatusCode::DistributionBlocked, ReleaseFlowStatusCode::DistributionReady, ]; diff --git a/infrastructure/src/release_ops.rs b/infrastructure/src/release_ops.rs new file mode 100644 index 0000000..10d2cbc --- /dev/null +++ b/infrastructure/src/release_ops.rs @@ -0,0 +1,1557 @@ +//! Rust-owned dual-release queries, distribution selection and safe cleanup. +//! +//! This module is deliberately read-heavy. It derives its view from the +//! existing official/localized state files, release manifests and filesystem; +//! it does not introduce another release database or lifecycle state machine. + +use crate::localized_patch::{ + inspect_localized_release_artifact_at, read_localized_patch_manifest_at, + read_localized_version_state, LOCALIZED_CURRENT_LINK, LOCALIZED_STAGING_DIR, + LOCALIZED_VERSIONS_DIR, +}; +use crate::official_download::{ + read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references, + OfficialDownloadManifest, +}; +use crate::official_update::{read_version_state, OfficialVersionRecord, OfficialVersionState}; +use crate::path_security::{ + ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, +}; +use crate::ReleaseFlowStatusCode; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +const OFFICIAL_CURRENT_LINK: &str = "current"; +const OFFICIAL_STAGING_DIR: &str = ".staging"; +const OFFICIAL_VERSIONS_DIR: &str = "versions"; +const OFFICIAL_VERSION_STATE_FILE: &str = "official-version-state.json"; + +/// Parameters for release listing. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ReleaseListParams { + /// Optional `official` or `localized` namespace. + pub channel: Option, +} + +/// Parameters for selecting a release for distribution. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ReleaseDistributionParams { + /// `official` or `localized`; omitted means official. + pub channel: Option, + /// Historical release ID; omitted means the current release for channel. + pub release_id: Option, + /// Entry page offset. + #[serde(default)] + pub offset: usize, + /// Entry page size. Zero uses the server default. + #[serde(default)] + pub limit: usize, +} + +/// Parameters for the two-step cleanup operation. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ReleaseCleanupParams { + /// Execute a previously inspected plan. + #[serde(default)] + pub execute: bool, + /// Plan fingerprint returned by a dry run. + pub plan_id: Option, +} + +/// One resource entry returned by an explicit distribution selection. +#[derive(Debug, Clone, Serialize)] +pub struct ReleaseDistributionEntry { + /// Official URL retained by the resource manifest. + pub url: String, + /// Release-relative destination. + pub destination: String, + /// Expected byte length. + pub bytes: u64, + /// Expected BLAKE3. + pub blake3: String, +} + +/// Compact summary for one official or localized historical release. +#[derive(Debug, Clone, Serialize)] +pub struct ReleaseSummary { + /// Release namespace. + pub channel: String, + /// Stable release directory identity. + pub id: String, + /// Version directory. + pub path: PathBuf, + /// Official source for a localized release. + pub source_official_release_id: Option, + /// Creation/start time where known. + pub created_unix_seconds: Option, + /// Publication time where known. + pub published_unix_seconds: Option, + /// Whether this is the channel's current release. + pub current: bool, + /// Whether the release has a safe, matching current pointer. + pub current_pointer_valid: bool, + /// Whether the current localized release has a verified rollback target. + pub rollback_available: bool, + /// Whether the release is stale relative to the current official source. + pub stale: bool, + /// Whether recorded artifacts or publication identity are damaged. + pub damaged: bool, + /// Whether state, current, rollback, source or other ownership references it. + pub referenced: bool, + /// Whether the release has incomplete identity or integrity evidence. + pub unknown: bool, + /// Lifecycle label. + pub lifecycle: String, + /// Manifest schema/contract status. + pub manifest_contract_status: String, + /// Recorded changed-file artifact status. + pub artifact_integrity_status: String, + /// Full distributable resource-root status. + pub distribution_integrity_status: String, + /// Legacy localized wrapper without a generic manifest. + pub legacy: bool, + /// Rollback previous release ID, when recorded. + pub rollback_previous_release_id: Option, + /// Diagnostic details. + pub diagnostics: Vec, +} + +/// Unified read-only view of both release namespaces. +#[derive(Debug, Clone, Serialize)] +pub struct ReleaseStatusReport { + /// Stable report status. + pub status: String, + /// Stable namespaced status code. + pub status_code: String, + /// Current official release ID. + pub official_current_release_id: Option, + /// Current localized release ID. + pub localized_current_release_id: Option, + /// Official source used by the current localized release. + pub localized_source_official_release_id: Option, + /// Whether current localized and official releases match. + pub current_releases_match: bool, + /// Default distribution choice, always official. + pub default_distribution_channel: String, + /// Whether the default official current release is distributable. + pub official_distribution_ready: bool, + /// Whether current localized release is verified and distributable. + pub localized_distribution_ready: bool, + /// All historical summaries. + pub releases: Vec, +} + +/// A page of resources from a Rust-verified release choice. +#[derive(Debug, Clone, Serialize)] +pub struct ReleaseDistributionPage { + /// Whether the selected root can be served. + pub available: bool, + /// Selected namespace. + pub channel: String, + /// Selected stable ID. + pub release_id: Option, + /// Filesystem root chosen by Rust. + pub resource_root: Option, + /// Official source for localized selection. + pub source_official_release_id: Option, + /// Whether selection is the channel current release. + pub current: bool, + /// Stable selection status. + pub status: String, + /// Stable selection status code. + pub status_code: String, + /// Artifact integrity status. + pub artifact_integrity_status: String, + /// Full entry count before paging. + pub total: usize, + /// Requested offset. + pub offset: usize, + /// Effective page size. + pub limit: usize, + /// Verified manifest entries. + pub entries: Vec, + /// Selection diagnostics. + pub diagnostics: Vec, +} + +/// One cleanup observation, either retained or removable. +#[derive(Debug, Clone, Serialize)] +pub struct ReleaseCleanupEntry { + /// Release namespace. + pub channel: String, + /// Directory identity. + pub id: String, + /// Managed path. + pub path: PathBuf, + /// Whether execution may remove this entry. + pub candidate: bool, + /// Reasons it is retained. + pub retain_reasons: Vec, + /// References which block deletion. + pub blocking_references: Vec, +} + +/// Dry-run or execute cleanup result. +#[derive(Debug, Clone, Serialize)] +pub struct ReleaseCleanupReport { + /// Whether this call executed deletion. + pub execute: bool, + /// Stable plan fingerprint. + pub plan_id: String, + /// Whether execute revalidated the requested plan. + pub revalidated: bool, + /// All observed release entries. + pub entries: Vec, + /// Paths removed during this call. + pub removed: Vec, + /// Plan or execution diagnostics. + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Channel { + Official, + Localized, +} + +impl Channel { + fn parse(value: Option<&str>) -> anyhow::Result { + match value.unwrap_or("official") { + "official" => Ok(Self::Official), + "localized" => Ok(Self::Localized), + other => Err(anyhow::anyhow!( + "release channel 必须是 official 或 localized,实际={other}" + )), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Official => "official", + Self::Localized => "localized", + } + } +} + +/// Builds the unified status view. +pub fn build_release_status( + official_root: &Path, + localized_root: &Path, + unzip_command: &Path, +) -> anyhow::Result { + let official_state = read_version_state(&official_root.join(OFFICIAL_VERSION_STATE_FILE))?; + let localized_state = read_localized_version_state(localized_root)?; + let official_current_id = official_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()) + .map(|record| record.id.clone()); + let localized_current_id = localized_state + .as_ref() + .and_then(|state| state.current_release_id.clone()); + let localized_source_id = localized_state + .as_ref() + .map(|state| state.official_release_id.clone()); + let mut official = list_official_releases(official_root, official_state.as_ref())?; + let localized = list_localized_releases( + official_root, + localized_root, + localized_state.as_ref(), + official_current_id.as_deref(), + unzip_command, + )?; + let localized_source_references = localized + .iter() + .filter_map(|release| release.source_official_release_id.as_deref()) + .collect::>(); + for release in &mut official { + release.referenced |= localized_source_references.contains(release.id.as_str()); + } + let official_ready = official + .iter() + .any(|release| release.current && release.distribution_integrity_status == "valid"); + let localized_ready = localized + .iter() + .any(|release| release.current && release.distribution_integrity_status == "valid"); + let current_match = official_current_id.is_some() + && localized_source_id == official_current_id + && localized_current_id.is_some() + && localized_ready; + let mut releases = official; + releases.extend(localized); + releases.sort_by(|left, right| { + left.channel + .cmp(&right.channel) + .then_with(|| left.id.cmp(&right.id)) + }); + let status_code = if official_ready { + ReleaseFlowStatusCode::DistributionReady + } else { + ReleaseFlowStatusCode::DistributionBlocked + }; + Ok(ReleaseStatusReport { + status: status_code.status().to_string(), + status_code: status_code.as_str().to_string(), + official_current_release_id: official_current_id, + localized_current_release_id: localized_current_id, + localized_source_official_release_id: localized_source_id, + current_releases_match: current_match, + default_distribution_channel: "official".to_string(), + official_distribution_ready: official_ready, + localized_distribution_ready: localized_ready, + releases, + }) +} + +/// Lists one or both release namespaces. +pub fn build_release_list( + official_root: &Path, + localized_root: &Path, + params: &ReleaseListParams, + unzip_command: &Path, +) -> anyhow::Result { + let status = build_release_status(official_root, localized_root, unzip_command)?; + let channel = params + .channel + .as_deref() + .map(|value| Channel::parse(Some(value)).map(|channel| channel.as_str().to_string())); + let channel = match channel { + Some(result) => Some(result?), + None => None, + }; + let releases = status + .releases + .into_iter() + .filter(|release| { + channel + .as_deref() + .is_none_or(|value| value == release.channel) + }) + .collect::>(); + Ok(serde_json::json!({ + "status": status.status, + "status_code": status.status_code, + "channel": channel, + "releases": releases, + })) +} + +/// Selects only a Rust-verified official or localized root for distribution. +pub fn select_release_distribution( + official_root: &Path, + localized_root: &Path, + params: &ReleaseDistributionParams, + unzip_command: &Path, +) -> anyhow::Result { + let channel = Channel::parse(params.channel.as_deref())?; + let status = build_release_status(official_root, localized_root, unzip_command)?; + let selected_id = params.release_id.as_deref(); + let selected = status.releases.iter().find(|release| { + release.channel == channel.as_str() + && selected_id.is_none_or(|id| release.id == id) + && (selected_id.is_some() || release.current) + }); + let Some(selected) = selected else { + return Ok(blocked_distribution( + channel, + selected_id.map(str::to_string), + "请求的 release 不存在或不是当前 release", + )); + }; + if selected.distribution_integrity_status != "valid" { + return Ok(blocked_distribution( + channel, + Some(selected.id.clone()), + "release 产物完整性未通过,拒绝分发", + )); + } + let root = selected.path.clone(); + let manifest_root = selected + .source_official_release_id + .as_deref() + .map(|source| official_root.join(OFFICIAL_VERSIONS_DIR).join(source)) + .unwrap_or_else(|| root.clone()); + let manifest = read_download_manifest_at(&manifest_root) + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("release 缺少官方下载 manifest"))?; + let all_entries = manifest + .entries + .values() + .map(|entry| ReleaseDistributionEntry { + url: entry.url.clone(), + destination: entry.destination.clone(), + bytes: entry.bytes, + blake3: entry.blake3.clone(), + }) + .collect::>(); + let total = all_entries.len(); + let offset = params.offset.min(total); + let limit = if params.limit == 0 { + 1000 + } else { + params.limit.min(1000) + }; + let entries = all_entries.into_iter().skip(offset).take(limit).collect(); + Ok(ReleaseDistributionPage { + available: true, + channel: channel.as_str().to_string(), + release_id: Some(selected.id.clone()), + resource_root: Some(root), + source_official_release_id: selected.source_official_release_id.clone(), + current: selected.current, + status: ReleaseFlowStatusCode::DistributionReady + .status() + .to_string(), + status_code: ReleaseFlowStatusCode::DistributionReady + .as_str() + .to_string(), + artifact_integrity_status: selected.artifact_integrity_status.clone(), + total, + offset, + limit, + entries, + diagnostics: selected.diagnostics.clone(), + }) +} + +fn blocked_distribution( + channel: Channel, + release_id: Option, + diagnostic: &str, +) -> ReleaseDistributionPage { + ReleaseDistributionPage { + available: false, + channel: channel.as_str().to_string(), + release_id, + resource_root: None, + source_official_release_id: None, + current: false, + status: ReleaseFlowStatusCode::DistributionBlocked + .status() + .to_string(), + status_code: ReleaseFlowStatusCode::DistributionBlocked + .as_str() + .to_string(), + artifact_integrity_status: "invalid".to_string(), + total: 0, + offset: 0, + limit: 0, + entries: Vec::new(), + diagnostics: vec![diagnostic.to_string()], + } +} + +/// Builds a dry-run cleanup plan or executes a matching plan. +pub fn cleanup_releases( + official_root: &Path, + localized_root: &Path, + cas_root: &Path, + params: &ReleaseCleanupParams, + unzip_command: &Path, +) -> anyhow::Result { + let plan = build_cleanup_plan(official_root, localized_root, cas_root, unzip_command)?; + if !params.execute { + return Ok(ReleaseCleanupReport { + execute: false, + plan_id: plan.plan_id, + revalidated: false, + entries: plan.entries, + removed: Vec::new(), + diagnostics: plan.diagnostics, + }); + } + let requested = params + .plan_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("execute cleanup 必须提供 dry-run 返回的 plan_id"))?; + if requested != plan.plan_id { + return Err(anyhow::anyhow!( + "cleanup plan 已过期,请重新执行 dry-run:requested={} current={}", + requested, + plan.plan_id + )); + } + let mut removed = Vec::new(); + for entry in &plan.entries { + if !entry.candidate { + continue; + } + let root = if entry.channel == Channel::Official.as_str() { + official_root + } else { + localized_root + }; + let managed_root = root.join(if entry.id.starts_with(".staging/") { + if entry.channel == Channel::Official.as_str() { + OFFICIAL_STAGING_DIR + } else { + LOCALIZED_STAGING_DIR + } + } else if entry.channel == Channel::Official.as_str() { + OFFICIAL_VERSIONS_DIR + } else { + LOCALIZED_VERSIONS_DIR + }); + let id = entry.id.rsplit('/').next().unwrap_or(""); + let path = managed_root.join(id); + if !entry.path_is_same(&path) { + return Err(anyhow::anyhow!( + "cleanup plan 路径发生变化,拒绝删除:{}", + entry.path.display() + )); + } + if fs::symlink_metadata(&path) + .map(|metadata| !metadata.is_dir()) + .unwrap_or(true) + { + return Err(anyhow::anyhow!( + "cleanup candidate 不是普通目录,拒绝删除:{}", + path.display() + )); + } + ensure_safe_directory_path(&path, "release cleanup candidate") + .map_err(anyhow::Error::msg)?; + if entry.channel == Channel::Official.as_str() + && !entry.id.starts_with(".staging/") + && read_cas_reuse_reference_manifest_at(&path) + .map_err(anyhow::Error::msg)? + .is_some() + { + let _ = release_cas_reuse_references(&path, cas_root).map_err(anyhow::Error::msg)?; + } + fs::remove_dir_all(&path)?; + removed.push(path); + } + Ok(ReleaseCleanupReport { + execute: true, + plan_id: plan.plan_id, + revalidated: true, + entries: plan.entries, + removed, + diagnostics: plan.diagnostics, + }) +} + +#[derive(Debug)] +struct CleanupPlan { + plan_id: String, + entries: Vec, + diagnostics: Vec, +} + +impl ReleaseCleanupEntry { + fn path_is_same(&self, other: &Path) -> bool { + self.path == other + } +} + +fn build_cleanup_plan( + official_root: &Path, + localized_root: &Path, + cas_root: &Path, + unzip_command: &Path, +) -> anyhow::Result { + let official_state = read_version_state(&official_root.join(OFFICIAL_VERSION_STATE_FILE))?; + let localized_state = read_localized_version_state(localized_root)?; + let status = build_release_status(official_root, localized_root, unzip_command)?; + let mut official_references = BTreeSet::new(); + let mut localized_references = BTreeSet::new(); + let mut diagnostics = Vec::new(); + if let Some(state) = official_state.as_ref() { + protect_official_state_references(state, &mut official_references); + } + if let Some(state) = localized_state.as_ref() { + if let Some(id) = state.current_release_id.as_deref() { + localized_references.insert(id.to_string()); + } + official_references.insert(state.official_release_id.clone()); + } + for release in status + .releases + .iter() + .filter(|release| release.channel == "localized") + { + if let Some(source) = release.source_official_release_id.as_deref() { + // A persisted localized manifest is an ownership reference even + // when that historical localized release is no longer current. + official_references.insert(source.to_string()); + } + if let Some(previous) = release.rollback_previous_release_id.as_deref() { + localized_references.insert(previous.to_string()); + } + if release.legacy { + localized_references.insert(release.id.clone()); + } + } + let mut entries = Vec::new(); + collect_cleanup_versions( + official_root, + Channel::Official, + &official_references, + cas_root, + &mut entries, + &mut diagnostics, + )?; + collect_cleanup_versions( + localized_root, + Channel::Localized, + &localized_references, + cas_root, + &mut entries, + &mut diagnostics, + )?; + collect_cleanup_staging( + official_root, + Channel::Official, + official_state.as_ref(), + &mut entries, + &mut diagnostics, + )?; + collect_cleanup_staging( + localized_root, + Channel::Localized, + None, + &mut entries, + &mut diagnostics, + )?; + let fingerprint = serde_json::to_vec(&(&entries, &diagnostics))?; + let plan_id = blake3::hash(&fingerprint).to_hex().to_string(); + Ok(CleanupPlan { + plan_id, + entries, + diagnostics, + }) +} + +fn protect_official_state_references( + state: &OfficialVersionState, + references: &mut BTreeSet, +) { + if let Some(record) = state.current_completed_version.as_ref() { + references.insert(record.id.clone()); + } + if let Some(record) = state.previous_available_version.as_ref() { + references.insert(record.id.clone()); + } + if let Some(record) = state.in_progress_version.as_ref() { + references.insert(record.id.clone()); + } + for failed in &state.failed_versions { + references.insert(failed.version.id.clone()); + } +} + +fn collect_cleanup_versions( + root: &Path, + channel: Channel, + references: &BTreeSet, + cas_root: &Path, + entries: &mut Vec, + diagnostics: &mut Vec, +) -> anyhow::Result<()> { + let versions_root = root.join(if channel == Channel::Official { + OFFICIAL_VERSIONS_DIR + } else { + LOCALIZED_VERSIONS_DIR + }); + let Ok(read_dir) = fs::read_dir(&versions_root) else { + return Ok(()); + }; + let current_id = read_managed_current_id( + &root.join(if channel == Channel::Official { + OFFICIAL_CURRENT_LINK + } else { + LOCALIZED_CURRENT_LINK + }), + if channel == Channel::Official { + OFFICIAL_VERSIONS_DIR + } else { + LOCALIZED_VERSIONS_DIR + }, + ); + for item in read_dir { + let item = item?; + let path = item.path(); + let name = item.file_name().to_string_lossy().into_owned(); + if !is_safe_release_id(&name) { + entries.push(retained_entry( + channel, + name, + path, + "无法确定 ownership 的版本项", + Vec::new(), + )); + continue; + } + let metadata = fs::symlink_metadata(&path)?; + if !metadata.is_dir() { + entries.push(retained_entry( + channel, + name, + path, + "版本项不是普通目录", + Vec::new(), + )); + continue; + } + let mut blocking = Vec::new(); + let mut reasons = Vec::new(); + if references.contains(&name) { + reasons.push("被状态、current、rollback 或 localized source 引用".to_string()); + blocking.push(name.clone()); + } + if current_id.as_deref() == Some(name.as_str()) { + reasons.push("current 指针目标".to_string()); + blocking.push("current".to_string()); + } + if channel == Channel::Localized { + match read_localized_patch_manifest_at(&path) { + Ok(Some(manifest)) + if is_safe_release_id(&manifest.official_release_id) + && manifest.localized_release_id == name => {} + Ok(Some(_)) => { + reasons.push("localized manifest identity 无法确认 ownership".to_string()); + } + Ok(None) => { + reasons.push("缺少可确认归属的 localized manifest".to_string()); + } + Err(error) => { + diagnostics.push(format!( + "读取 localized manifest 失败 {}:{error}", + path.display() + )); + reasons.push("localized manifest 损坏,无法确认 ownership".to_string()); + } + } + } + if channel == Channel::Official { + match read_cas_reuse_reference_manifest_at(&path) { + Ok(Some(_)) + if !cas_root.join("objects").is_dir() + || !cas_root.join("metadata.sqlite").is_file() => + { + reasons.push("CAS 根目录不可验证,保留 release".to_string()); + } + Ok(_) => {} + Err(error) => { + diagnostics.push(format!("读取 CAS 引用失败 {}:{error}", path.display())); + reasons.push("CAS 引用清单损坏,保留".to_string()); + } + } + } + if reasons.is_empty() { + entries.push(ReleaseCleanupEntry { + channel: channel.as_str().to_string(), + id: name, + path, + candidate: true, + retain_reasons: Vec::new(), + blocking_references: Vec::new(), + }); + } else { + entries.push(ReleaseCleanupEntry { + channel: channel.as_str().to_string(), + id: name, + path, + candidate: false, + retain_reasons: reasons, + blocking_references: blocking, + }); + } + } + Ok(()) +} + +fn collect_cleanup_staging( + root: &Path, + channel: Channel, + state: Option<&OfficialVersionState>, + entries: &mut Vec, + _diagnostics: &mut Vec, +) -> anyhow::Result<()> { + let staging_root = root.join(if channel == Channel::Official { + OFFICIAL_STAGING_DIR + } else { + LOCALIZED_STAGING_DIR + }); + let Ok(read_dir) = fs::read_dir(&staging_root) else { + return Ok(()); + }; + let active = state + .and_then(|state| state.in_progress_version.as_ref()) + .map(|record| record.id.clone()); + for item in read_dir { + let item = item?; + let path = item.path(); + let id = item.file_name().to_string_lossy().into_owned(); + if !is_safe_release_id(&id) || !fs::symlink_metadata(&path)?.is_dir() { + entries.push(retained_entry( + channel, + format!(".staging/{id}"), + path, + "无法确定 staging ownership", + Vec::new(), + )); + continue; + } + let active_match = active.as_deref() == Some(id.as_str()); + entries.push(ReleaseCleanupEntry { + channel: channel.as_str().to_string(), + id: format!(".staging/{id}"), + path, + candidate: false, + retain_reasons: if active_match { + vec!["官方状态标记为 in-progress".to_string()] + } else { + vec!["staging 默认保留,避免误删未持久化任务".to_string()] + }, + blocking_references: active.clone().into_iter().collect(), + }); + } + Ok(()) +} + +fn retained_entry( + channel: Channel, + id: String, + path: PathBuf, + reason: &str, + blocking_references: Vec, +) -> ReleaseCleanupEntry { + ReleaseCleanupEntry { + channel: channel.as_str().to_string(), + id, + path, + candidate: false, + retain_reasons: vec![reason.to_string()], + blocking_references, + } +} + +fn list_official_releases( + root: &Path, + state: Option<&OfficialVersionState>, +) -> anyhow::Result> { + let versions_root = root.join(OFFICIAL_VERSIONS_DIR); + let current_id = state + .and_then(|state| state.current_completed_version.as_ref()) + .map(|record| record.id.as_str()); + let pointer_id = + read_managed_current_id(&root.join(OFFICIAL_CURRENT_LINK), OFFICIAL_VERSIONS_DIR); + let records = state_records(state); + let mut ids = BTreeSet::new(); + if let Ok(read_dir) = fs::read_dir(&versions_root) { + for item in read_dir { + let item = item?; + if is_safe_release_id(&item.file_name().to_string_lossy()) { + ids.insert(item.file_name().to_string_lossy().into_owned()); + } + } + } + let mut releases = Vec::new(); + for id in ids { + let path = versions_root.join(&id); + let record = records.get(&id); + let (artifact, diagnostics) = verify_official_release(&path); + let artifact_valid = artifact.1 == "valid"; + let current = current_id == Some(id.as_str()); + let pointer_valid = pointer_id.as_deref() == Some(id.as_str()); + let referenced = records.contains_key(&id) || pointer_valid; + let mut diagnostics = diagnostics; + if current && !pointer_valid { + diagnostics.push("official current 与状态不一致".to_string()); + } + let rollback_previous_release_id = current + .then(|| { + state + .and_then(|state| state.previous_available_version.as_ref()) + .map(|previous| previous.id.clone()) + }) + .flatten(); + let (manifest_contract_status, artifact_integrity_status, distribution_integrity_status) = + if current && !pointer_valid { + (artifact.0, "invalid".to_string(), "invalid".to_string()) + } else { + artifact + }; + releases.push(ReleaseSummary { + channel: "official".to_string(), + id, + path, + source_official_release_id: None, + created_unix_seconds: record.and_then(|record| record.started_unix_seconds), + published_unix_seconds: record.and_then(|record| record.completed_unix_seconds), + current, + current_pointer_valid: pointer_valid, + rollback_available: false, + stale: current && !pointer_valid, + damaged: !artifact_valid || (current && !pointer_valid), + referenced, + unknown: manifest_contract_status == "missing" + || artifact_integrity_status == "unknown" + || distribution_integrity_status == "unknown", + lifecycle: if current { + if artifact_valid && pointer_valid { + "current".to_string() + } else if !artifact_valid { + "damaged".to_string() + } else { + "stale".to_string() + } + } else { + "historical".to_string() + }, + manifest_contract_status, + artifact_integrity_status, + distribution_integrity_status, + legacy: false, + rollback_previous_release_id, + diagnostics, + }); + } + Ok(releases) +} + +fn list_localized_releases( + official_root: &Path, + root: &Path, + state: Option<&crate::localized_patch::LocalizedVersionState>, + official_current_id: Option<&str>, + unzip_command: &Path, +) -> anyhow::Result> { + let versions_root = root.join(LOCALIZED_VERSIONS_DIR); + let current_id = state.and_then(|state| state.current_release_id.as_deref()); + let pointer_id = + read_managed_current_id(&root.join(LOCALIZED_CURRENT_LINK), LOCALIZED_VERSIONS_DIR); + let mut releases = Vec::new(); + let Ok(read_dir) = fs::read_dir(&versions_root) else { + return Ok(releases); + }; + for item in read_dir { + let item = item?; + let id = item.file_name().to_string_lossy().into_owned(); + if !is_safe_release_id(&id) { + continue; + } + let path = item.path(); + let manifest = read_localized_patch_manifest_at(&path); + let (source_id, contract, legacy, rollback, generated_unix_seconds, mut diagnostics) = + match manifest { + Ok(Some(manifest)) => ( + Some(manifest.official_release_id.clone()), + if manifest.patch_manifest.is_some() { + "valid".to_string() + } else { + "legacy".to_string() + }, + manifest.patch_manifest.is_none(), + release_id_from_path(&manifest.rollback.previous_current_target), + Some(manifest.generated_unix_seconds), + Vec::new(), + ), + Ok(None) => ( + None, + "missing".to_string(), + false, + None, + None, + vec![format!("缺少 localized manifest:{}", path.display())], + ), + Err(error) => ( + None, + "invalid".to_string(), + false, + None, + None, + vec![error.to_string()], + ), + }; + let expected_source = source_id + .as_deref() + .or_else(|| state.map(|state| state.official_release_id.as_str())) + .unwrap_or_default(); + let official_source_root = if is_safe_release_id(expected_source) { + official_root + .join(OFFICIAL_VERSIONS_DIR) + .join(expected_source) + } else { + diagnostics.push(format!( + "localized manifest official source identity 不安全:{expected_source}" + )); + official_root.to_path_buf() + }; + let inspection = inspect_localized_release_artifact_at( + &official_source_root, + root, + &id, + expected_source, + unzip_command, + ); + diagnostics.extend(inspection.diagnostics.clone()); + let full_status = if inspection.verified { + verify_full_distribution(&official_source_root, &path) + } else { + "invalid".to_string() + }; + let current = current_id == Some(id.as_str()); + let pointer_valid = pointer_id.as_deref() == Some(id.as_str()); + let stale = source_id + .as_deref() + .zip(official_current_id) + .is_some_and(|(source, current)| source != current) + || (current && !pointer_valid); + let damaged = inspection.artifact_integrity_status != "valid" + || full_status == "invalid" + || (current && !pointer_valid); + let rollback_available = current + && pointer_valid + && rollback.as_deref().is_some_and(|id| { + let target = root.join(LOCALIZED_VERSIONS_DIR).join(id); + is_safe_release_id(id) + && fs::symlink_metadata(&target) + .map(|metadata| metadata.is_dir()) + .unwrap_or(false) + && ensure_safe_directory_path(&target, "localized rollback target").is_ok() + }); + let unknown = source_id.is_none() + || contract == "missing" + || inspection.artifact_integrity_status == "unavailable" + || inspection.artifact_integrity_status == "unknown" + || full_status == "unknown"; + if current && !pointer_valid { + diagnostics.push("localized current 与状态不一致".to_string()); + } + releases.push(ReleaseSummary { + channel: "localized".to_string(), + id, + path, + source_official_release_id: source_id, + created_unix_seconds: generated_unix_seconds, + published_unix_seconds: generated_unix_seconds, + current, + current_pointer_valid: pointer_valid, + rollback_available, + stale, + damaged, + referenced: current || pointer_valid || legacy || rollback.is_some(), + unknown, + lifecycle: if damaged { + "damaged".to_string() + } else if unknown { + "unknown".to_string() + } else if stale { + "stale".to_string() + } else if current { + "current".to_string() + } else { + "historical".to_string() + }, + manifest_contract_status: if inspection.manifest_contract_status == "missing" { + contract + } else { + inspection.manifest_contract_status + }, + artifact_integrity_status: inspection.artifact_integrity_status, + distribution_integrity_status: if full_status == "valid" && (!current || pointer_valid) + { + "valid".to_string() + } else { + full_status + }, + legacy, + rollback_previous_release_id: rollback, + diagnostics, + }); + } + let rollback_references = releases + .iter() + .filter_map(|release| release.rollback_previous_release_id.as_deref()) + .map(str::to_string) + .collect::>(); + for release in &mut releases { + release.referenced |= rollback_references.contains(release.id.as_str()); + } + Ok(releases) +} + +fn verify_official_release(path: &Path) -> ((String, String, String), Vec) { + let mut diagnostics = Vec::new(); + let Some(manifest) = (match read_download_manifest_at(path) { + Ok(manifest) => manifest, + Err(error) => { + diagnostics.push(error); + None + } + }) else { + diagnostics.push("缺少官方下载 manifest".to_string()); + return ( + ( + "missing".to_string(), + "unknown".to_string(), + "unknown".to_string(), + ), + diagnostics, + ); + }; + if let Err(error) = verify_download_manifest(path, &manifest) { + diagnostics.push(error.to_string()); + } + let status = if diagnostics.is_empty() { + "valid" + } else { + "invalid" + }; + ( + ("valid".to_string(), status.to_string(), status.to_string()), + diagnostics, + ) +} + +fn verify_download_manifest( + root: &Path, + manifest: &OfficialDownloadManifest, +) -> anyhow::Result<()> { + for entry in manifest.entries.values() { + let path = safe_manifest_file_path(root, &entry.destination)?; + let bytes = fs::read(&path)?; + let hash = blake3::hash(&bytes).to_hex().to_string(); + if bytes.len() as u64 != entry.bytes || hash != entry.blake3 { + return Err(anyhow::anyhow!( + "official artifact integrity mismatch {}", + entry.destination + )); + } + } + Ok(()) +} + +fn verify_full_distribution(official_root: &Path, localized_root: &Path) -> String { + let Ok(Some(manifest)) = read_download_manifest_at(official_root) else { + return "unknown".to_string(); + }; + let changed_paths = read_localized_patch_manifest_at(localized_root) + .ok() + .flatten() + .map(|manifest| { + manifest + .files + .into_iter() + .map(|file| file.path) + .collect::>() + }) + .unwrap_or_default(); + for entry in manifest.entries.values() { + let Ok(path) = safe_manifest_file_path(localized_root, &entry.destination) else { + return "invalid".to_string(); + }; + let Ok(bytes) = fs::read(path) else { + return "invalid".to_string(); + }; + if !changed_paths.contains(&entry.destination) + && (bytes.len() as u64 != entry.bytes + || blake3::hash(&bytes).to_hex().to_string() != entry.blake3) + { + return "invalid".to_string(); + } + } + "valid".to_string() +} + +fn safe_manifest_file_path(root: &Path, destination: &str) -> anyhow::Result { + if destination.is_empty() || destination.contains('\\') || destination.contains('\0') { + return Err(anyhow::anyhow!( + "manifest destination 不安全:{destination}" + )); + } + let path = root.join(destination); + ensure_path_within_root(root, &path).map_err(anyhow::Error::msg)?; + ensure_safe_file_target(root, &path, "release manifest file").map_err(anyhow::Error::msg)?; + Ok(path) +} + +fn state_records(state: Option<&OfficialVersionState>) -> BTreeMap { + let mut records = BTreeMap::new(); + if let Some(state) = state { + for record in [ + state.current_completed_version.as_ref(), + state.in_progress_version.as_ref(), + state.previous_available_version.as_ref(), + ] + .into_iter() + .flatten() + { + records.insert(record.id.clone(), record); + } + for failed in &state.failed_versions { + records.insert(failed.version.id.clone(), &failed.version); + } + } + records +} + +fn read_managed_current_id(current_path: &Path, versions_dir: &str) -> Option { + let target = fs::read_link(current_path).ok()?; + if target.is_absolute() { + return None; + } + let mut components = target.components(); + match (components.next(), components.next(), components.next()) { + (Some(Component::Normal(root)), Some(Component::Normal(id)), None) + if root == versions_dir => + { + let id = id.to_str()?.to_string(); + is_safe_release_id(&id).then_some(id) + } + _ => None, + } +} + +fn release_id_from_path(path: &Option) -> Option { + let path = path.as_deref()?; + let mut components = path.components(); + match (components.next(), components.next(), components.next()) { + (Some(Component::Normal(root)), Some(Component::Normal(id)), None) + if root == LOCALIZED_VERSIONS_DIR => + { + let id = id.to_str()?.to_string(); + is_safe_release_id(&id).then_some(id) + } + _ => None, + } +} + +fn is_safe_release_id(id: &str) -> bool { + !id.is_empty() + && id != "." + && id != ".." + && !id.contains('/') + && !id.contains('\\') + && !id.contains(':') + && !id.contains('\0') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + write_localized_version_state, LocalizedPatchConfig, LocalizedPatchService, + OfficialDownloadManifestEntry, OfficialVersionState, + }; + use std::os::unix::fs::symlink; + + fn official_record(root: &Path, id: &str) -> OfficialVersionRecord { + OfficialVersionRecord { + id: id.to_string(), + app_version: "app".to_string(), + bundle_version: Some("bundle".to_string()), + addressables_root: "root".to_string(), + resource_root: root.join(OFFICIAL_VERSIONS_DIR).join(id), + snapshot_path: root.join("snapshot.json"), + staging_path: None, + version_path: Some(root.join(OFFICIAL_VERSIONS_DIR).join(id)), + started_unix_seconds: Some(1), + completed_unix_seconds: Some(2), + } + } + + fn prepare_official(root: &Path, id: &str) -> PathBuf { + let version = root.join(OFFICIAL_VERSIONS_DIR).join(id); + fs::create_dir_all(&version).unwrap(); + let data = b"official"; + fs::write(version.join("data.bin"), data).unwrap(); + let manifest = OfficialDownloadManifest { + version: 1, + entries: [( + "https://example.invalid/data.bin".to_string(), + OfficialDownloadManifestEntry { + url: "https://example.invalid/data.bin".to_string(), + destination: "data.bin".to_string(), + bytes: data.len() as u64, + blake3: blake3::hash(data).to_hex().to_string(), + }, + )] + .into_iter() + .collect(), + }; + fs::write( + version.join("official-download-manifest.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + version + } + + #[test] + fn status_lists_current_official_and_localized_release_relation() { + let temp = tempfile::tempdir().unwrap(); + let official_root = temp.path().join("official"); + let localized_root = temp.path().join("localized"); + let official_version = prepare_official(&official_root, "official-v1"); + fs::create_dir_all(&official_root).unwrap(); + symlink( + Path::new(OFFICIAL_VERSIONS_DIR).join("official-v1"), + official_root.join(OFFICIAL_CURRENT_LINK), + ) + .unwrap(); + fs::write( + official_root.join(OFFICIAL_VERSION_STATE_FILE), + serde_json::to_vec(&OfficialVersionState { + current_completed_version: Some(official_record(&official_root, "official-v1")), + ..OfficialVersionState::default() + }) + .unwrap(), + ) + .unwrap(); + LocalizedPatchService::new() + .publish(&LocalizedPatchConfig::new( + &official_version, + &localized_root, + "official-v1", + Vec::new(), + )) + .unwrap(); + + let report = + build_release_status(&official_root, &localized_root, Path::new("unzip")).unwrap(); + + assert!(report.official_distribution_ready); + assert!(report.localized_distribution_ready); + assert!(report.current_releases_match); + assert_eq!( + report.localized_source_official_release_id.as_deref(), + Some("official-v1") + ); + let localized = report + .releases + .iter() + .find(|release| release.channel == "localized") + .unwrap(); + assert!(localized.created_unix_seconds.is_some()); + assert_eq!( + localized.created_unix_seconds, + localized.published_unix_seconds + ); + assert!(!localized.rollback_available); + assert!(!localized.unknown); + assert_eq!(report.releases.len(), 2); + } + + #[test] + fn distribution_selection_does_not_fallback_from_damaged_localized_release() { + let temp = tempfile::tempdir().unwrap(); + let official_root = temp.path().join("official"); + let localized_root = temp.path().join("localized"); + let official_version = prepare_official(&official_root, "official-v1"); + fs::create_dir_all(&official_root).unwrap(); + symlink( + Path::new(OFFICIAL_VERSIONS_DIR).join("official-v1"), + official_root.join(OFFICIAL_CURRENT_LINK), + ) + .unwrap(); + fs::write( + official_root.join(OFFICIAL_VERSION_STATE_FILE), + serde_json::to_vec(&OfficialVersionState { + current_completed_version: Some(official_record(&official_root, "official-v1")), + ..OfficialVersionState::default() + }) + .unwrap(), + ) + .unwrap(); + let report = LocalizedPatchService::new() + .publish(&LocalizedPatchConfig::new( + &official_version, + &localized_root, + "official-v1", + Vec::new(), + )) + .unwrap(); + fs::write(report.version_path.join("data.bin"), b"damaged").unwrap(); + + let selected = select_release_distribution( + &official_root, + &localized_root, + &ReleaseDistributionParams { + channel: Some("localized".to_string()), + ..ReleaseDistributionParams::default() + }, + Path::new("unzip"), + ) + .unwrap(); + assert!(!selected.available); + assert_eq!(selected.channel, "localized"); + assert!(selected.resource_root.is_none()); + } + + #[test] + fn cleanup_plan_protects_current_and_removes_only_unreferenced_history() { + let temp = tempfile::tempdir().unwrap(); + let official_root = temp.path().join("official"); + let localized_root = temp.path().join("localized"); + let current = prepare_official(&official_root, "current"); + prepare_official(&official_root, "old"); + fs::create_dir_all(&official_root).unwrap(); + symlink( + Path::new(OFFICIAL_VERSIONS_DIR).join("current"), + official_root.join(OFFICIAL_CURRENT_LINK), + ) + .unwrap(); + fs::write( + official_root.join(OFFICIAL_VERSION_STATE_FILE), + serde_json::to_vec(&OfficialVersionState { + current_completed_version: Some(official_record(&official_root, "current")), + ..OfficialVersionState::default() + }) + .unwrap(), + ) + .unwrap(); + let _ = current; + fs::create_dir_all(&localized_root).unwrap(); + write_localized_version_state( + &localized_root, + &crate::LocalizedVersionState { + state_version: crate::LOCALIZED_VERSION_STATE_VERSION, + official_release_id: "current".to_string(), + current_release_id: None, + status: "not_localized".to_string(), + translation_workflow_status: None, + updated_unix_seconds: 1, + }, + ) + .unwrap(); + + let dry_run = cleanup_releases( + &official_root, + &localized_root, + &temp.path().join("cas"), + &ReleaseCleanupParams::default(), + Path::new("unzip"), + ) + .unwrap(); + assert!(dry_run + .entries + .iter() + .any(|entry| entry.id == "old" && entry.candidate)); + assert!(dry_run + .entries + .iter() + .any(|entry| entry.id == "current" && !entry.candidate)); + + fs::write( + official_root.join(OFFICIAL_VERSION_STATE_FILE), + serde_json::to_vec(&OfficialVersionState { + current_completed_version: Some(official_record(&official_root, "old")), + ..OfficialVersionState::default() + }) + .unwrap(), + ) + .unwrap(); + let stale = cleanup_releases( + &official_root, + &localized_root, + &temp.path().join("cas"), + &ReleaseCleanupParams { + execute: true, + plan_id: Some(dry_run.plan_id.clone()), + }, + Path::new("unzip"), + ); + assert!(stale.is_err()); + assert!(official_root + .join(OFFICIAL_VERSIONS_DIR) + .join("old") + .exists()); + + fs::write( + official_root.join(OFFICIAL_VERSION_STATE_FILE), + serde_json::to_vec(&OfficialVersionState { + current_completed_version: Some(official_record(&official_root, "current")), + ..OfficialVersionState::default() + }) + .unwrap(), + ) + .unwrap(); + let executed = cleanup_releases( + &official_root, + &localized_root, + &temp.path().join("cas"), + &ReleaseCleanupParams { + execute: true, + plan_id: Some(dry_run.plan_id), + }, + Path::new("unzip"), + ) + .unwrap(); + assert!(executed.revalidated); + assert!(!official_root + .join(OFFICIAL_VERSIONS_DIR) + .join("old") + .exists()); + assert!(official_root + .join(OFFICIAL_VERSIONS_DIR) + .join("current") + .exists()); + } + + #[cfg(unix)] + #[test] + fn cleanup_retains_symlinks_and_localized_identity_injection() { + let temp = tempfile::tempdir().unwrap(); + let official_root = temp.path().join("official"); + let localized_root = temp.path().join("localized"); + let versions = localized_root.join(LOCALIZED_VERSIONS_DIR); + fs::create_dir_all(&versions).unwrap(); + + let invalid = versions.join("localized-bad"); + fs::create_dir_all(&invalid).unwrap(); + let manifest = crate::LocalizedPatchManifest { + manifest_version: crate::LOCALIZED_PATCH_MANIFEST_VERSION, + official_release_id: "../outside".to_string(), + localized_release_id: "localized-bad".to_string(), + generated_unix_seconds: 1, + file_count: 0, + text_asset_operation_count: 0, + files: Vec::new(), + patch_manifest: None, + rollback: crate::LocalizedPatchRollbackInfo { + previous_current_target: None, + remove_version_path: PathBuf::from("versions/localized-bad"), + }, + }; + fs::write( + invalid.join(crate::LOCALIZED_PATCH_MANIFEST_FILE), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + + let outside = temp.path().join("outside"); + fs::create_dir_all(&outside).unwrap(); + symlink(&outside, versions.join("symlink")).unwrap(); + + let report = cleanup_releases( + &official_root, + &localized_root, + &temp.path().join("cas"), + &ReleaseCleanupParams::default(), + Path::new("unzip"), + ) + .unwrap(); + let invalid_entry = report + .entries + .iter() + .find(|entry| entry.id == "localized-bad") + .unwrap(); + assert!(!invalid_entry.candidate); + assert!(invalid_entry + .retain_reasons + .iter() + .any(|reason| reason.contains("ownership"))); + let symlink_entry = report + .entries + .iter() + .find(|entry| entry.id == "symlink") + .unwrap(); + assert!(!symlink_entry.candidate); + assert!(outside.exists()); + } +} diff --git a/internal/api/admin.go b/internal/api/admin.go index 2a8865e..3b6ece7 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -85,6 +85,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/control/translation-glossary-delete", "/admin/control/localized-publish", "/admin/control/localized-rollback", + "/admin/control/release-cleanup", }, } if r.Method == http.MethodHead { @@ -144,6 +145,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { s.handleAdminLocalizedRollback(w, r) return } + if action == "release-cleanup" { + s.handleAdminReleaseCleanup(w, r) + return + } request, ok := decodeAdminControlRequest(w, r) if !ok { return @@ -619,6 +624,34 @@ func (s *Server) handleAdminLocalizedStatus(w http.ResponseWriter, r *http.Reque writeNoStoreJSON(w, http.StatusOK, result) } +func (s *Server) handleAdminReleaseCleanup(w http.ResponseWriter, r *http.Request) { + backend, ok := s.backend.(ReleaseBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable") + return + } + var params backendrpc.ReleaseCleanupParams + if !decodeAdminTranslationJSON(w, r, ¶ms) { + return + } + if params.Execute && strings.TrimSpace(params.PlanID) == "" { + writeErrorJSON(w, http.StatusBadRequest, "invalid_release_cleanup_params", "execute cleanup requires plan_id from a dry run") + return + } + result, err := backend.ReleaseCleanup(r.Context(), params) + if err != nil { + s.writeControlBackendError(w, "release-cleanup", err) + return + } + writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ + Service: "bat-api", + Action: "release-cleanup", + RPCMethod: "release.cleanup", + Status: "accepted", + Result: result, + }) +} + func (s *Server) handleAdminDiagnostics(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") diff --git a/internal/api/cdn.go b/internal/api/cdn.go index 9fba2fc..01d2934 100644 --- a/internal/api/cdn.go +++ b/internal/api/cdn.go @@ -14,11 +14,6 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - idx := s.index() - if idx == nil || idx.ResourceRoot == "" { - http.Error(w, "resource root not ready", http.StatusServiceUnavailable) - return - } _, rel, err := SplitCDNPath(r.URL.Path) if err != nil { http.NotFound(w, r) @@ -27,15 +22,47 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) { var entry ResourceEntry var hasEntry bool - if s.cfg.RequireIndexed { - entry, hasEntry = idx.Lookup(rel) + resourceRoot := "" + explicitRelease := false + channel := r.URL.Query().Get("channel") + releaseID := r.URL.Query().Get("release_id") + if channel != "" || releaseID != "" { + explicitRelease = true + channel, releaseID, selectorErr := releaseSelector(r) + if selectorErr != nil { + http.Error(w, selectorErr.Error(), http.StatusBadRequest) + return + } + selected, selectErr := s.loadReleaseDistribution(r, channel, releaseID) + if selectErr != nil { + http.Error(w, selectErr.Error(), http.StatusServiceUnavailable) + return + } + if selected == nil || !selected.Available || selected.ResourceRoot == "" { + http.Error(w, "selected release is not distributable", http.StatusConflict) + return + } + resourceRoot = selected.ResourceRoot + entry, hasEntry = releaseDistributionEntry(selected, rel) + } else { + idx := s.index() + if idx == nil || idx.ResourceRoot == "" { + http.Error(w, "resource root not ready", http.StatusServiceUnavailable) + return + } + resourceRoot = idx.ResourceRoot + if s.cfg.RequireIndexed { + entry, hasEntry = idx.Lookup(rel) + } + } + if explicitRelease || s.cfg.RequireIndexed { if !hasEntry || !entry.Present || !entry.SizeMatch { http.NotFound(w, r) return } } - abs, err := ResolveUnderRoot(idx.ResourceRoot, rel, true) + abs, err := ResolveUnderRoot(resourceRoot, rel, true) if err != nil { http.NotFound(w, r) return @@ -45,7 +72,7 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - if s.cfg.RequireIndexed && s.cfg.VerifySize { + if (explicitRelease || s.cfg.RequireIndexed) && s.cfg.VerifySize { if hasEntry && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes { http.Error(w, "size mismatch with release index", http.StatusConflict) return diff --git a/internal/api/openapi.go b/internal/api/openapi.go index e44599a..a7616dd 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -79,6 +79,52 @@ paths: responses: "200": description: Release summary. + /v1/releases: + get: + summary: Rust-owned official and localized release history + parameters: + - name: channel + in: query + schema: + type: string + enum: [official, localized] + responses: + "200": + description: Release history and manifest/artifact integrity summaries. + "503": + description: Rust bat release backend is unavailable. + /v1/distribution: + get: + summary: Select a verified official or localized release for distribution + parameters: + - name: channel + in: query + schema: + type: string + enum: [official, localized] + default: official + - name: release_id + in: query + schema: + type: string + - name: offset + in: query + schema: + type: integer + minimum: 0 + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + responses: + "200": + description: Rust-verified selected release and resource manifest page. + "409": + description: Selected release is missing, stale, damaged, or not distributable. + "503": + description: Rust bat release backend is unavailable. /v1/resources: get: summary: Paginated resource manifest entries @@ -551,6 +597,32 @@ paths: description: Missing or invalid admin token. "503": description: Rust bat localized backend is unavailable. + /admin/releases/status: + get: + summary: Read the unified Rust-owned release status view + responses: + "200": + description: Official/localized current relation and integrity status. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat release backend is unavailable. + /admin/releases: + get: + summary: Read Rust-owned historical release summaries + parameters: + - name: channel + in: query + schema: + type: string + enum: [official, localized] + responses: + "200": + description: Historical release summaries. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat release backend is unavailable. /admin/control/{action}: post: summary: Forward an allowlisted control or schedule action to Rust bat @@ -560,7 +632,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback] + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback, release-cleanup] requestBody: required: false content: diff --git a/internal/api/release_http.go b/internal/api/release_http.go new file mode 100644 index 0000000..2d92379 --- /dev/null +++ b/internal/api/release_http.go @@ -0,0 +1,239 @@ +package api + +import ( + "net/http" + "strconv" + "strings" + + "bat-api/internal/backendrpc" +) + +func (s *Server) handleReleaseList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + backend, ok := s.backend.(ReleaseBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable") + return + } + channel := r.URL.Query().Get("channel") + result, err := backend.ReleaseList(r.Context(), backendrpc.ReleaseListParams{Channel: channel}) + if err != nil { + s.writeControlBackendError(w, "release-list", err) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + +func (s *Server) handleReleaseDistribution(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + channel, releaseID, err := releaseSelector(r) + if err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid_release_selector", err.Error()) + return + } + params, err := releaseDistributionParams(r, channel, releaseID) + if err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid_release_query", err.Error()) + return + } + page, err := s.requestReleaseDistribution(r, params) + if err != nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", err.Error()) + return + } + if page == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust release distribution returned no result") + return + } + status := http.StatusOK + if !page.Available { + status = http.StatusConflict + } + writeNoStoreJSON(w, status, page) +} + +func (s *Server) handleAdminReleaseStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + backend, ok := s.backend.(ReleaseBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable") + return + } + result, err := backend.ReleaseStatus(r.Context()) + if err != nil { + s.writeControlBackendError(w, "release-status", err) + return + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + +func (s *Server) handleAdminReleaseList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + backend, ok := s.backend.(ReleaseBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable") + return + } + result, err := backend.ReleaseList(r.Context(), backendrpc.ReleaseListParams{ + Channel: r.URL.Query().Get("channel"), + }) + if err != nil { + s.writeControlBackendError(w, "release-list", err) + return + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + +func releaseSelector(r *http.Request) (string, string, error) { + channel := strings.TrimSpace(r.URL.Query().Get("channel")) + releaseID := strings.TrimSpace(r.URL.Query().Get("release_id")) + if channel == "" { + channel = "official" + } + if channel != "official" && channel != "localized" { + return "", "", &releaseSelectorError{message: "channel must be official or localized"} + } + if releaseID == "." || releaseID == ".." || + strings.Contains(releaseID, "/") || + strings.Contains(releaseID, "\\") || + strings.Contains(releaseID, ":") || + strings.ContainsRune(releaseID, 0) { + return "", "", &releaseSelectorError{message: "release_id contains an unsafe path character"} + } + return channel, releaseID, nil +} + +type releaseSelectorError struct { + message string +} + +func (e *releaseSelectorError) Error() string { + return e.message +} + +func (s *Server) loadReleaseDistribution(r *http.Request, channel, releaseID string) (*backendrpc.ReleaseDistributionPage, error) { + return s.loadReleaseDistributionFrom(r, backendrpc.ReleaseDistributionParams{ + Channel: channel, + ReleaseID: releaseID, + Offset: 0, + Limit: 1000, + }) +} + +func releaseDistributionParams(r *http.Request, channel, releaseID string) (backendrpc.ReleaseDistributionParams, error) { + params := backendrpc.ReleaseDistributionParams{ + Channel: channel, + ReleaseID: releaseID, + } + query := r.URL.Query() + if raw := strings.TrimSpace(query.Get("offset")); raw != "" { + offset, err := strconv.ParseUint(raw, 10, 64) + if err != nil || uint64(int(^uint(0)>>1)) < offset { + return backendrpc.ReleaseDistributionParams{}, &releaseSelectorError{ + message: "offset must be a non-negative integer", + } + } + params.Offset = int(offset) + } + if raw := strings.TrimSpace(query.Get("limit")); raw != "" { + limit, err := strconv.ParseUint(raw, 10, 64) + if err != nil || limit == 0 || limit > 1000 { + return backendrpc.ReleaseDistributionParams{}, &releaseSelectorError{ + message: "limit must be in 1..=1000", + } + } + params.Limit = int(limit) + } + return params, nil +} + +func (s *Server) requestReleaseDistribution(r *http.Request, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) { + backend, ok := s.backend.(ReleaseBackend) + if !ok || backend == nil { + return nil, &releaseSelectorError{message: "Rust bat release backend is unavailable"} + } + return backend.ReleaseDistribution(r.Context(), params) +} + +func (s *Server) loadReleaseDistributionFrom(r *http.Request, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) { + backend, ok := s.backend.(ReleaseBackend) + if !ok || backend == nil { + return nil, &releaseSelectorError{message: "Rust bat release backend is unavailable"} + } + pageSize := 1000 + result, err := backend.ReleaseDistribution(r.Context(), params) + if err != nil || result == nil || !result.Available || result.Total <= len(result.Entries) { + return result, err + } + all := append([]backendrpc.ReleaseDistributionEntry(nil), result.Entries...) + for offset := len(all); offset < result.Total; { + next, nextErr := backend.ReleaseDistribution(r.Context(), backendrpc.ReleaseDistributionParams{ + Channel: params.Channel, + ReleaseID: params.ReleaseID, + Offset: offset, + Limit: pageSize, + }) + if nextErr != nil { + return nil, nextErr + } + if next == nil || !next.Available || len(next.Entries) == 0 { + return nil, &releaseSelectorError{message: "Rust release distribution page is incomplete"} + } + all = append(all, next.Entries...) + offset = len(all) + if len(all) > result.Total { + all = all[:result.Total] + break + } + } + result.Entries = all + result.Offset = 0 + result.Limit = len(all) + return result, nil +} + +func releaseDistributionEntry(page *backendrpc.ReleaseDistributionPage, rel string) (ResourceEntry, bool) { + rel = strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/") + for _, entry := range page.Entries { + destination := strings.TrimPrefix(strings.ReplaceAll(entry.Destination, "\\", "/"), "/") + if destination == rel { + return ResourceEntry{ + URL: entry.URL, + RelativePath: destination, + Bytes: entry.Bytes, + BLAKE3: entry.BLAKE3, + Present: true, + SizeMatch: true, + }, true + } + } + return ResourceEntry{}, false +} diff --git a/internal/api/release_http_test.go b/internal/api/release_http_test.go new file mode 100644 index 0000000..22062d4 --- /dev/null +++ b/internal/api/release_http_test.go @@ -0,0 +1,175 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "bat-api/internal/backendrpc" +) + +type releaseBackendStub struct { + *fakeBackend + root string + available bool + distributionParams []backendrpc.ReleaseDistributionParams +} + +func (b *controlBackend) ReleaseStatus(context.Context) (*backendrpc.ReleaseStatusReport, error) { + b.calls = append(b.calls, "release.status") + return &backendrpc.ReleaseStatusReport{Status: "ready", StatusCode: "distribution.ready"}, nil +} + +func (b *controlBackend) ReleaseList(context.Context, backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) { + b.calls = append(b.calls, "release.list") + return &backendrpc.ReleaseListReport{Status: "ready", StatusCode: "distribution.ready"}, nil +} + +func (b *controlBackend) ReleaseDistribution(context.Context, backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) { + b.calls = append(b.calls, "release.distribution") + return &backendrpc.ReleaseDistributionPage{Available: false, StatusCode: "distribution.blocked"}, nil +} + +func (b *controlBackend) ReleaseCleanup(context.Context, backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) { + b.calls = append(b.calls, "release.cleanup") + return &backendrpc.ReleaseCleanupReport{PlanID: "plan-1"}, nil +} + +func (b *releaseBackendStub) ReleaseStatus(context.Context) (*backendrpc.ReleaseStatusReport, error) { + return &backendrpc.ReleaseStatusReport{ + Status: "ready", + StatusCode: "distribution.ready", + OfficialCurrentReleaseID: "official-1", + DefaultDistributionChannel: "official", + OfficialDistributionReady: true, + }, nil +} + +func (b *releaseBackendStub) ReleaseList(context.Context, backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) { + return &backendrpc.ReleaseListReport{ + Status: "ready", + StatusCode: "distribution.ready", + Releases: []backendrpc.ReleaseSummary{{ + Channel: "localized", + ID: "localized-1", + ManifestContractStatus: "valid", + ArtifactIntegrityStatus: "valid", + DistributionIntegrityStatus: "valid", + }}, + }, nil +} + +func (b *releaseBackendStub) ReleaseDistribution(_ context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) { + b.distributionParams = append(b.distributionParams, params) + return &backendrpc.ReleaseDistributionPage{ + Available: b.available, + Channel: params.Channel, + ReleaseID: params.ReleaseID, + ResourceRoot: b.root, + Status: "ready", + StatusCode: "distribution.ready", + ArtifactIntegrityStatus: "valid", + Total: 1, + Limit: 1000, + Entries: []backendrpc.ReleaseDistributionEntry{{ + URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes", + Destination: "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes", + Bytes: 21, + BLAKE3: "not-used-by-http-index", + }}, + }, nil +} + +func (*releaseBackendStub) ReleaseCleanup(context.Context, backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) { + return &backendrpc.ReleaseCleanupReport{PlanID: "plan-1"}, nil +} + +func TestReleaseHTTPForwardsTypedSelectionAndDoesNotFallback(t *testing.T) { + cfg := DefaultConfig() + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + backend := &releaseBackendStub{fakeBackend: &fakeBackend{}, root: fixtureRoot(t), available: true} + server := NewServer(cfg, backend, nil) + + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/releases?channel=localized", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("release list status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + recorder = httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/distribution?channel=localized&release_id=localized-1&offset=2&limit=10", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("distribution status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if len(backend.distributionParams) != 1 || + backend.distributionParams[0].Channel != "localized" || + backend.distributionParams[0].ReleaseID != "localized-1" || + backend.distributionParams[0].Offset != 2 || + backend.distributionParams[0].Limit != 10 { + t.Fatalf("distribution params=%#v", backend.distributionParams) + } + + recorder = httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=localized&release_id=localized-1", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("localized CDN status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if recorder.Body.String() != "TABLE_CATALOG_FIXTURE" { + t.Fatalf("localized CDN body=%q", recorder.Body.String()) + } + + cfg.RequireIndexed = false + unindexedServer := NewServer(cfg, backend, nil) + recorder = httptest.NewRecorder() + unindexedServer.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/not-listed.bytes?channel=localized&release_id=localized-1", nil)) + if recorder.Code != http.StatusNotFound { + t.Fatalf("unlisted localized CDN status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + blocked := &releaseBackendStub{fakeBackend: &fakeBackend{}, root: fixtureRoot(t), available: false} + blockedServer := NewServer(cfg, blocked, nil) + recorder = httptest.NewRecorder() + blockedServer.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=localized", nil)) + if recorder.Code != http.StatusConflict { + t.Fatalf("blocked localized CDN status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestAdminReleaseCleanupRequiresAuthAndForwards(t *testing.T) { + cfg := DefaultConfig() + cfg.AuthToken = "control-token" + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + backend := &controlBackend{fakeBackend: &fakeBackend{}} + server := NewServer(cfg, backend, nil) + + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/admin/control/release-cleanup", nil)) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated cleanup status=%d", recorder.Code) + } + + request := httptest.NewRequest(http.MethodPost, "/admin/control/release-cleanup", strings.NewReader(`{"execute":false}`)) + request.Header.Set("Authorization", "Bearer control-token") + recorder = httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusAccepted { + t.Fatalf("dry-run cleanup status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if len(backend.calls) != 1 || backend.calls[0] != "release.cleanup" { + t.Fatalf("calls=%v", backend.calls) + } + + request = httptest.NewRequest(http.MethodPost, "/admin/control/release-cleanup", strings.NewReader(`{"execute":true}`)) + request.Header.Set("Authorization", "Bearer control-token") + recorder = httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("missing plan cleanup status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index 9215d0b..0c650f2 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -113,6 +113,15 @@ type LocalizedBackend interface { LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) } +// ReleaseBackend exposes Rust-owned dual-release queries, distribution +// selection and the explicit cleanup operation. +type ReleaseBackend interface { + ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) + ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) + ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) + ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) +} + // RPCClient adapts *backendrpc.Client to Backend. type RPCClient struct { Client *backendrpc.Client @@ -253,6 +262,22 @@ func (r RPCClient) LocalizedRollback(ctx context.Context, params backendrpc.Loca return r.Client.LocalizedRollback(ctx, params) } +func (r RPCClient) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) { + return r.Client.ReleaseStatus(ctx) +} + +func (r RPCClient) ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) { + return r.Client.ReleaseList(ctx, params) +} + +func (r RPCClient) ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) { + return r.Client.ReleaseDistribution(ctx, params) +} + +func (r RPCClient) ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) { + return r.Client.ReleaseCleanup(ctx, params) +} + func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) { return r.Client.ParseStatus(ctx) } diff --git a/internal/api/server.go b/internal/api/server.go index c90ddb0..d20b194 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -51,6 +51,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/v1/bootstrap", s.handleBootstrap) mux.HandleFunc("/v1/launcher/bootstrap", s.handleLauncherBootstrap) mux.HandleFunc("/v1/release", s.handleRelease) + mux.HandleFunc("/v1/releases", s.handleReleaseList) + mux.HandleFunc("/v1/distribution", s.handleReleaseDistribution) mux.HandleFunc("/v1/resources", s.handleResources) mux.HandleFunc("/v1/server-info", s.handleServerInfoDebug) mux.HandleFunc("/api/launcher/game/config", s.handleLauncherGameConfig) @@ -80,6 +82,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/admin/translation/glossary/query", s.handleAdminGlossaryQuery) mux.HandleFunc("/admin/translation/glossary/diagnose", s.handleAdminGlossaryDiagnose) mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus) + mux.HandleFunc("/admin/releases/status", s.handleAdminReleaseStatus) + mux.HandleFunc("/admin/releases", s.handleAdminReleaseList) mux.HandleFunc("/admin/control/", s.handleAdminControl) mux.HandleFunc("/admin/", s.handleAdminIndex) mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN) @@ -132,6 +136,8 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { "/v1/bootstrap", "/v1/launcher/bootstrap", "/v1/release", + "/v1/releases", + "/v1/distribution", "/v1/resources", "/v1/server-info", "/api/launcher/game/config", @@ -162,6 +168,8 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { "/admin/translation/glossary/query", "/admin/translation/glossary/diagnose", "/admin/translation/status", + "/admin/releases/status", + "/admin/releases", "/admin/control/{action}", }, }) diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index da026b6..8520609 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -182,6 +182,117 @@ type pageParam struct { Limit int `json:"limit"` } +// ReleaseListParams selects one Rust-owned release namespace. +type ReleaseListParams struct { + Channel string `json:"channel,omitempty"` +} + +// ReleaseDistributionParams selects a verified release for distribution. +type ReleaseDistributionParams struct { + Channel string `json:"channel,omitempty"` + ReleaseID string `json:"release_id,omitempty"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// ReleaseCleanupParams controls the dry-run/execute cleanup pair. +type ReleaseCleanupParams struct { + Execute bool `json:"execute,omitempty"` + PlanID string `json:"plan_id,omitempty"` +} + +// ReleaseSummary mirrors Rust's dual-release historical summary. +type ReleaseSummary struct { + Channel string `json:"channel"` + ID string `json:"id"` + Path string `json:"path"` + SourceOfficialReleaseID string `json:"source_official_release_id,omitempty"` + CreatedUnixSeconds *uint64 `json:"created_unix_seconds,omitempty"` + PublishedUnixSeconds *uint64 `json:"published_unix_seconds,omitempty"` + Current bool `json:"current"` + CurrentPointerValid bool `json:"current_pointer_valid"` + RollbackAvailable bool `json:"rollback_available"` + Stale bool `json:"stale"` + Damaged bool `json:"damaged"` + Referenced bool `json:"referenced"` + Unknown bool `json:"unknown"` + Lifecycle string `json:"lifecycle"` + ManifestContractStatus string `json:"manifest_contract_status"` + ArtifactIntegrityStatus string `json:"artifact_integrity_status"` + DistributionIntegrityStatus string `json:"distribution_integrity_status"` + Legacy bool `json:"legacy"` + RollbackPreviousReleaseID string `json:"rollback_previous_release_id,omitempty"` + Diagnostics []string `json:"diagnostics,omitempty"` +} + +// ReleaseStatusReport is the unified official/localized release view. +type ReleaseStatusReport struct { + Status string `json:"status"` + StatusCode string `json:"status_code"` + OfficialCurrentReleaseID string `json:"official_current_release_id,omitempty"` + LocalizedCurrentReleaseID string `json:"localized_current_release_id,omitempty"` + LocalizedSourceOfficialID string `json:"localized_source_official_release_id,omitempty"` + CurrentReleasesMatch bool `json:"current_releases_match"` + DefaultDistributionChannel string `json:"default_distribution_channel"` + OfficialDistributionReady bool `json:"official_distribution_ready"` + LocalizedDistributionReady bool `json:"localized_distribution_ready"` + Releases []ReleaseSummary `json:"releases"` +} + +// ReleaseListReport is the filtered historical release response. +type ReleaseListReport struct { + Status string `json:"status"` + StatusCode string `json:"status_code"` + Channel string `json:"channel,omitempty"` + Releases []ReleaseSummary `json:"releases"` +} + +// ReleaseDistributionEntry is one Rust-verified resource manifest entry. +type ReleaseDistributionEntry struct { + URL string `json:"url"` + Destination string `json:"destination"` + Bytes uint64 `json:"bytes"` + BLAKE3 string `json:"blake3"` +} + +// ReleaseDistributionPage is a typed page for one selected release. +type ReleaseDistributionPage struct { + Available bool `json:"available"` + Channel string `json:"channel"` + ReleaseID string `json:"release_id,omitempty"` + ResourceRoot string `json:"resource_root,omitempty"` + SourceOfficialReleaseID string `json:"source_official_release_id,omitempty"` + Current bool `json:"current"` + Status string `json:"status"` + StatusCode string `json:"status_code"` + ArtifactIntegrityStatus string `json:"artifact_integrity_status"` + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Entries []ReleaseDistributionEntry `json:"entries"` + Diagnostics []string `json:"diagnostics,omitempty"` +} + +// ReleaseCleanupEntry is one retained or removable cleanup observation. +type ReleaseCleanupEntry struct { + Channel string `json:"channel"` + ID string `json:"id"` + Path string `json:"path"` + Candidate bool `json:"candidate"` + RetainReasons []string `json:"retain_reasons,omitempty"` + BlockingReferences []string `json:"blocking_references,omitempty"` +} + +// ReleaseCleanupReport is the dry-run or execute result. +type ReleaseCleanupReport struct { + Execute bool `json:"execute"` + PlanID string `json:"plan_id"` + Revalidated bool `json:"revalidated"` + Entries []ReleaseCleanupEntry `json:"entries"` + Removed []string `json:"removed"` + Diagnostics []string `json:"diagnostics,omitempty"` +} + type tailParam struct { Tail int `json:"tail"` } @@ -981,6 +1092,30 @@ func (c *Client) CatalogDiff(ctx context.Context) (json.RawMessage, error) { return c.rawData(ctx, "catalog.diff", nil) } +func (c *Client) ReleaseStatus(ctx context.Context) (*ReleaseStatusReport, error) { + var out ReleaseStatusReport + _, err := c.Call(ctx, "release.status", nil, &out) + return &out, err +} + +func (c *Client) ReleaseList(ctx context.Context, params ReleaseListParams) (*ReleaseListReport, error) { + var out ReleaseListReport + _, err := c.Call(ctx, "release.list", params, &out) + return &out, err +} + +func (c *Client) ReleaseDistribution(ctx context.Context, params ReleaseDistributionParams) (*ReleaseDistributionPage, error) { + var out ReleaseDistributionPage + _, err := c.Call(ctx, "release.distribution", params, &out) + return &out, err +} + +func (c *Client) ReleaseCleanup(ctx context.Context, params ReleaseCleanupParams) (*ReleaseCleanupReport, error) { + var out ReleaseCleanupReport + _, err := c.Call(ctx, "release.cleanup", params, &out) + return &out, err +} + func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) { var out TaskAccepted _, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out) diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index d1cf690..2231a3b 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -88,6 +88,55 @@ func TestResourceRepairQueuesTask(t *testing.T) { } } +func TestReleaseDistributionUsesTypedRPCContract(t *testing.T) { + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + if req.Method != "release.distribution" { + t.Fatalf("method = %s", req.Method) + } + var params ReleaseDistributionParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decode params: %v", err) + } + if params.Channel != "localized" || params.ReleaseID != "localized-1" || params.Offset != 2 || params.Limit != 10 { + t.Fatalf("params = %#v", params) + } + return testResponse{ + Result: testEnvelope{ + OK: true, + Status: "ok", + Data: map[string]any{ + "available": true, + "channel": "localized", + "release_id": "localized-1", + "resource_root": "/tmp/localized", + "total": 3, + "offset": 2, + "limit": 10, + "entries": []any{map[string]any{ + "url": "https://example.invalid/data.bin", + "destination": "host/data.bin", + "bytes": 4, + "blake3": "abcd", + }}, + }, + }, + } + }) + + page, err := client.ReleaseDistribution(context.Background(), ReleaseDistributionParams{ + Channel: "localized", + ReleaseID: "localized-1", + Offset: 2, + Limit: 10, + }) + if err != nil { + t.Fatalf("ReleaseDistribution error: %v", err) + } + if !page.Available || page.ResourceRoot != "/tmp/localized" || len(page.Entries) != 1 { + t.Fatalf("page = %#v", page) + } +} + func TestDaemonRestartSendsControlMethod(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "daemon.restart" {