mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
fix(release): 完成官方发布身份与 legacy CAS 归属保护
This commit is contained in:
+10
-5
@@ -1,6 +1,6 @@
|
|||||||
# BlueArchiveToolkit 当前工作区状态
|
# BlueArchiveToolkit 当前工作区状态
|
||||||
|
|
||||||
- **更新时间**:2026-09-12
|
- **更新时间**:2026-09-13
|
||||||
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
||||||
- **状态分支**:`experiment`
|
- **状态分支**:`experiment`
|
||||||
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
||||||
@@ -58,12 +58,17 @@ output-root 单写者锁和事务日志恢复
|
|||||||
current、version-state、version 目录,publish 只有最终 `verified` phase 才能恢复为已提交。
|
current、version-state、version 目录,publish 只有最终 `verified` phase 才能恢复为已提交。
|
||||||
localized release 还写入实际 bytes/BLAKE3、完整 source mapping identity 和 destination
|
localized release 还写入实际 bytes/BLAKE3、完整 source mapping identity 和 destination
|
||||||
index 的 `localized-distribution-manifest.json`;official download manifest 同样持久化
|
index 的 `localized-distribution-manifest.json`;official download manifest 同样持久化
|
||||||
canonical mapping identity 和 destination index。`release.distribution(destination=...)`
|
canonical mapping identity 和 destination index。新 official release 在完整发布验证后
|
||||||
只比较 persisted identity、查目标索引并校验单文件,返回 exactly one entry 且不在分发
|
额外写入独立的 `official-distribution-publication.json`,关联 release ID、mapping
|
||||||
热路径执行完整 release audit;`release.cleanup execute` 与 official sync 共用同一个
|
identity、manifest content identity 和 entry count。`release.distribution` 只选择
|
||||||
|
publication identity 与当前 official manifest 一致的 release;`destination=...` 时
|
||||||
|
比较 persisted identity、查目标索引并校验单文件,返回 exactly one entry 且不在分发热路径
|
||||||
|
执行完整 release audit;缺少 publication metadata 的 legacy release 仍可读和清理,但不
|
||||||
|
满足 distribution-ready。`release.cleanup execute` 与 official sync 共用同一个
|
||||||
`.official-sync.lock`,localized cleanup 继续使用 `.localized-release.lock`。legacy CAS
|
`.official-sync.lock`,localized cleanup 继续使用 `.localized-release.lock`。legacy CAS
|
||||||
manifest 首次 cleanup 时按 output-root scope、release generation 和稳定 source identity
|
manifest 首次 cleanup 时按 output-root scope、release generation 和稳定 source identity
|
||||||
迁移;已有 basename ledger 的部分 cleanup 保持兼容 key,完成后同名新 generation 不再复用。
|
迁移;已有 basename ledger 的部分 cleanup 只有在 output-root 持久化的显式 compatibility
|
||||||
|
marker 存在时才保持兼容 key,无法证明归属时拒绝 cleanup;完成后同名新 generation 不再复用。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ trusted 和 release/TextUnit/provider/run provenance;`translation.tasks` 优
|
|||||||
8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。
|
8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。
|
||||||
9. 远端变化、本地 audit 发现 repair_needed,首次空目录运行,或缺少 `current` 原子发布指针时,进入下载/发布流程。
|
9. 远端变化、本地 audit 发现 repair_needed,首次空目录运行,或缺少 `current` 原子发布指针时,进入下载/发布流程。
|
||||||
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。新 staging 还会扫描已发布 release 的下载 manifest,按规范化 destination 查找候选并重新验证 size、BLAKE3 和 ZIP 结构;硬链接失败时回退到临时文件复制和原子 rename,历史 release 保持不可变。
|
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。新 staging 还会扫描已发布 release 的下载 manifest,按规范化 destination 查找候选并重新验证 size、BLAKE3 和 ZIP 结构;硬链接失败时回退到临时文件复制和原子 rename,历史 release 保持不可变。
|
||||||
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot,并在 staging 中写入 `official-launcher-bootstrap.json`(若本轮启用 `--auto-discover`)。
|
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后,在 staging 中写入独立的 `official-distribution-publication.json` 发布事实和新的 snapshot,并写入 `official-launcher-bootstrap.json`(若本轮启用 `--auto-discover`)。publication 文件关联 official release ID、完整 mapping identity、manifest content identity 和 entry count;缺少或不匹配时 release 不可作为 distribution-ready。
|
||||||
12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/current` symlink 指向该 versioned 目录。
|
12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/current` symlink 指向该 versioned 目录。
|
||||||
13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。
|
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 状态。历史 release 复用只允许不可变资源 payload/sidecar 硬链接;download manifest、snapshot、parse/textunit cache、queue、handoff、bootstrap、CAS reuse references 以及 `translation-tasks.sqlite`、WAL/SHM 都必须独立复制,不能共享可变 inode。
|
14. 随后刷新 active release 下的 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json`、`crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。历史 release 复用只允许不可变资源 payload/sidecar 硬链接;download manifest、snapshot、parse/textunit cache、queue、handoff、bootstrap、CAS reuse references 以及 `translation-tasks.sqlite`、WAL/SHM 都必须独立复制,不能共享可变 inode。
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
current -> versions/<id> # 原子 symlink,生产读侧
|
current -> versions/<id> # 原子 symlink,生产读侧
|
||||||
versions/<id>/ # 已发布 versioned release(= resource_root)
|
versions/<id>/ # 已发布 versioned release(= resource_root)
|
||||||
official-download-manifest.json
|
official-download-manifest.json
|
||||||
|
official-distribution-publication.json # 独立发布事实:release、mapping、manifest identity、entry count
|
||||||
official-parse-cache.json # 校验后派生解析缓存,不是汉化产物
|
official-parse-cache.json # 校验后派生解析缓存,不是汉化产物
|
||||||
official-textunit-index.json # TextUnit 明细与解析错误索引,不是汉化产物
|
official-textunit-index.json # TextUnit 明细与解析错误索引,不是汉化产物
|
||||||
official-textunit-tasks.json # 翻译任务候选派生队列,不发 Crowdin 网络请求
|
official-textunit-tasks.json # 翻译任务候选派生队列,不发 Crowdin 网络请求
|
||||||
@@ -137,6 +138,15 @@ reference、无法确认 ownership 的对象均保留;只删除重新验证后
|
|||||||
跨文件系统时复制到 staging 内的临时文件并原子 rename,旧 `versions/<id>` 目录
|
跨文件系统时复制到 staging 内的临时文件并原子 rename,旧 `versions/<id>` 目录
|
||||||
保持不可变。
|
保持不可变。
|
||||||
|
|
||||||
|
新 official release 在完整下载、manifest、文件和 ZIP 校验完成后,才会在 versioned
|
||||||
|
目录中原子写入 `official-distribution-publication.json`。该文件独立记录
|
||||||
|
`official_release_id`、完整 distribution mapping identity、manifest content identity
|
||||||
|
(manifest 文件 BLAKE3)和 `entry_count`。普通 manifest 读写、release status 查询和
|
||||||
|
`release.distribution` 不会重建或刷新它;如果 manifest 在发布后变化、publication
|
||||||
|
文件缺失或两者 identity 不一致,该 release 的 `distribution_integrity_status` 不是
|
||||||
|
`valid`,不能被 distribution 读侧选择。没有该文件的历史 release 仍可被状态/清理逻辑
|
||||||
|
识别为 `legacy`,但不会被当作 distribution-ready。
|
||||||
|
|
||||||
从 CAS 物化资源时,`official-cas-reuse-references.json` 首次创建时生成并持久化
|
从 CAS 物化资源时,`official-cas-reuse-references.json` 首次创建时生成并持久化
|
||||||
`ownership_id`,记录每个获取的对象引用;文件带版本字段且允许重复 object ID。
|
`ownership_id`,记录每个获取的对象引用;文件带版本字段且允许重复 object ID。
|
||||||
没有 `ownership_id` 的旧清单首次 cleanup 按 output-root scope、release ID、稳定 source
|
没有 `ownership_id` 的旧清单首次 cleanup 按 output-root scope、release ID、稳定 source
|
||||||
@@ -246,8 +256,20 @@ localized 或历史 release 时,调用 `release.distribution` 选择 Rust 已
|
|||||||
| `bytes` | 文件大小 |
|
| `bytes` | 文件大小 |
|
||||||
| `blake3` | 本地 BLAKE3 hex |
|
| `blake3` | 本地 BLAKE3 hex |
|
||||||
|
|
||||||
|
manifest 还持久化以下 distribution 查询字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `distribution_mapping_identity` | string | 完整 URL、destination、size、BLAKE3 映射的确定性 identity |
|
||||||
|
| `destination_index` | map destination → URL | 单 destination 分发的轻量定位索引 |
|
||||||
|
|
||||||
**权威清单**:拉取闭环写入的 manifest;`bat-api` / RPC `resource.manifest` 以此为应有集合,再以磁盘校验 Present。
|
**权威清单**:拉取闭环写入的 manifest;`bat-api` / RPC `resource.manifest` 以此为应有集合,再以磁盘校验 Present。
|
||||||
|
|
||||||
|
`official-distribution-publication.json` 是发布事实而不是 manifest 派生缓存。单条
|
||||||
|
distribution 查询只读取该文件、当前 manifest 的内容 identity 和
|
||||||
|
`destination_index`,再校验目标文件的 size/BLAKE3;不会为了定位一个 destination
|
||||||
|
重做完整 mapping canonicalization 或遍历其他资源。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 发现与 seed URL 规则(L2)
|
## 5. 发现与 seed URL 规则(L2)
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
|||||||
- `<output>/official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 与 remote manifest 文件列表 digest 都未变时复用缓存;任一变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
|
- `<output>/official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 与 remote manifest 文件列表 digest 都未变时复用缓存;任一变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
|
||||||
- `<output>/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
- `<output>/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||||
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size、BLAKE3、deterministic distribution mapping identity 和 destination index。
|
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size、BLAKE3、deterministic distribution mapping identity 和 destination index。
|
||||||
|
- `<output>/current/official-distribution-publication.json`:新 official release 完整校验后写入的独立发布事实,记录 official release ID、mapping identity、manifest content identity 和 entry count;它缺失或与 manifest 不一致时 release 不可分发。普通查询不会自动补写该文件。
|
||||||
- `<output>/current/official-cas-reuse-references.json`:当前 release 获取的 CAS 引用清单,首次创建时包含持久化 `ownership_id`;每个复用项占一条记录,release 清理或孤儿 staging GC 时据此按 ownership/ordinal 递减引用。旧无 identity 清单使用 output-root 的 `.cas-owner-scope` 完成 generation-aware legacy cleanup;该文件不会被复制到新 release。
|
- `<output>/current/official-cas-reuse-references.json`:当前 release 获取的 CAS 引用清单,首次创建时包含持久化 `ownership_id`;每个复用项占一条记录,release 清理或孤儿 staging GC 时据此按 ownership/ordinal 递减引用。旧无 identity 清单使用 output-root 的 `.cas-owner-scope` 完成 generation-aware legacy cleanup;该文件不会被复制到新 release。
|
||||||
- `<output>/current/official-resource-changes.json`:当前 release 相对上一完整 release 的资源差异,记录新增、变更、删除以及解析/翻译候选计数。
|
- `<output>/current/official-resource-changes.json`:当前 release 相对上一完整 release 的资源差异,记录新增、变更、删除以及解析/翻译候选计数。
|
||||||
- `<output>/current/crowdin-translation-handoff.json`:为后续 Crowdin worker 预留的本地队列,只包含新增+变更资源;它不是 Crowdin API 调用结果。
|
- `<output>/current/crowdin-translation-handoff.json`:为后续 Crowdin worker 预留的本地队列,只包含新增+变更资源;它不是 Crowdin API 调用结果。
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `release.status` | 已实现 | `null` | official/localized current、source relation、match、历史 release 和 manifest/artifact/distribution integrity 统一视图。 |
|
| `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.list` | 已实现 | `{ "channel": "official" }` 或 `{ "channel": "localized" }`,可省略 | 对应 namespace 的历史 release 摘要,包含 stable ID、created/published、current pointer、`rollback_available`、lifecycle、`stale`/`damaged`/`referenced`/`unknown`、legacy 和诊断。 |
|
||||||
| `release.distribution` | 已实现 | `{ "channel": "official", "release_id": "...", "destination": "...", "offset": 0, "limit": 1000 }`,均可省略 | Rust 选择的 verified `resource_root` 和 download manifest entries;有 `destination` 时是 single-entry lookup,响应固定 `total=1, offset=0, limit=1, entries.length=1`,使用 persisted source mapping identity/destination index,只校验该实际文件的 bytes/BLAKE3,不重新执行全量映射或资源 audit;无 `destination` 时保留管理查询分页语义。localized 使用发布时生成的实际字节 metadata,不复用 official size/hash;默认 channel 为 official,选择失败返回 `available=false`,不跨 channel fallback。 |
|
| `release.distribution` | 已实现 | `{ "channel": "official", "release_id": "...", "destination": "...", "offset": 0, "limit": 1000 }`,均可省略 | Rust 只选择具有独立 `official-distribution-publication.json` 且 identity 与当前 manifest 一致的 verified `resource_root`;有 `destination` 时是 single-entry lookup,响应固定 `total=1, offset=0, limit=1, entries.length=1`,使用 published mapping identity/destination index,只校验该实际文件的 bytes/BLAKE3,不重新执行全量映射或资源 audit;无 `destination` 时保留管理查询分页语义。localized 还必须匹配 source official 的 published identity,并使用发布时生成的实际字节 metadata,不复用 official size/hash;默认 channel 为 official,选择失败返回 `available=false`,不跨 channel fallback。 |
|
||||||
| `release.cleanup` | 已实现 | dry-run `{ "execute": false }`;执行 `{ "execute": true, "plan_id": "..." }` | cleanup plan、candidate/retain reasons、blocking references 和 removed paths;执行前会重新生成并比对 `plan_id`。 |
|
| `release.cleanup` | 已实现 | dry-run `{ "execute": false }`;执行 `{ "execute": true, "plan_id": "..." }` | cleanup plan、candidate/retain reasons、blocking references 和 removed paths;执行前会重新生成并比对 `plan_id`。 |
|
||||||
|
|
||||||
`release.status`、`release.list` 和 `release.distribution` 只读现有 official/localized
|
`release.status`、`release.list` 和 `release.distribution` 只读现有 official/localized
|
||||||
@@ -186,7 +186,9 @@ state、current、manifest、文件系统和 CAS/reference 元数据,不创建
|
|||||||
分发选择使用发布后的轻量 manifest 和文件 size/单文件 BLAKE3 校验,不在 HTTP 热路径
|
分发选择使用发布后的轻量 manifest 和文件 size/单文件 BLAKE3 校验,不在 HTTP 热路径
|
||||||
重新执行完整 release audit;localized 还要求 distribution manifest 的 destination/URL
|
重新执行完整 release audit;localized 还要求 distribution manifest 的 destination/URL
|
||||||
集合与 source official manifest 一致,并使用发布时记录的实际 localized bytes/hash。
|
集合与 source official manifest 一致,并使用发布时记录的实际 localized bytes/hash。
|
||||||
默认官方分发行为不变。
|
publication 文件缺失、manifest content identity 变化或 source mapping identity 不一致时,
|
||||||
|
即使单个目标文件本身完整,也返回 `available=false`;旧 release 可被列出并标记
|
||||||
|
`legacy`,但不会被 distribution 读侧自动重建 publication metadata。默认官方分发行为不变。
|
||||||
`release.cleanup` 只删除 Rust 能证明是普通目录且未被 current、rollback、staging、
|
`release.cleanup` 只删除 Rust 能证明是普通目录且未被 current、rollback、staging、
|
||||||
source、state、manifest、CAS 或未知 ownership 引用的历史项,不修改 current,也不承担
|
source、state、manifest、CAS 或未知 ownership 引用的历史项,不修改 current,也不承担
|
||||||
rollback 或 repair。
|
rollback 或 repair。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 当前实现缺口清单
|
# 当前实现缺口清单
|
||||||
|
|
||||||
- **更新时间**:2026-09-12
|
- **更新时间**:2026-09-13
|
||||||
- **文档角色**:只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态。
|
- **文档角色**:只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态。
|
||||||
- **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。
|
- **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。
|
||||||
- **Go 进度**:`GO_STATUS.md`
|
- **Go 进度**:`GO_STATUS.md`
|
||||||
@@ -137,7 +137,12 @@ source/localized mapping identity 和 destination index;`release.distribution(
|
|||||||
是单条 lookup,返回 exactly one entry,source identity mismatch 会阻断 localized
|
是单条 lookup,返回 exactly one entry,source identity mismatch 会阻断 localized
|
||||||
distribution,完整 identity 校验只在 publish/status/audit 路径执行;分发读取保留 path
|
distribution,完整 identity 校验只在 publish/status/audit 路径执行;分发读取保留 path
|
||||||
ownership、symlink 和文件完整性检查;`release.cleanup execute` 与 official sync 共用
|
ownership、symlink 和文件完整性检查;`release.cleanup execute` 与 official sync 共用
|
||||||
`.official-sync.lock`。本轮已关闭两个 Release/CAS P2。其他 P2 尚未由本轮处理:ResourceRepository
|
`.official-sync.lock`。新 official release 还持久化独立的
|
||||||
|
`official-distribution-publication.json`,把 release ID、完整 mapping identity、manifest
|
||||||
|
content identity 和 entry count 绑定到发布事实;publication 缺失或 manifest 变化时
|
||||||
|
official/localized distribution 均被阻断,普通查询不会自动重建。legacy release 仍可
|
||||||
|
列出和清理,但不视为 distribution-ready。本轮已关闭两个 Release/CAS P2。其他 P2
|
||||||
|
尚未由本轮处理:ResourceRepository
|
||||||
更完整的查询/权限/损坏恢复、模糊 TM、bat.sock peer credential/perms、FFI 生命周期、
|
更完整的查询/权限/损坏恢复、模糊 TM、bat.sock peer credential/perms、FFI 生命周期、
|
||||||
资源大小/限额与更强的持久化 fsync 语义仍按后续专项推进。
|
资源大小/限额与更强的持久化 fsync 语义仍按后续专项推进。
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ pub use official_download::{
|
|||||||
OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport,
|
OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport,
|
||||||
OfficialResourcePullService, OfficialResourcePullStatus, OfficialResourceReuseWarning,
|
OfficialResourcePullService, OfficialResourcePullStatus, OfficialResourceReuseWarning,
|
||||||
OfficialResourceVerification, OFFICIAL_CAS_REUSE_REFERENCES_FILE,
|
OfficialResourceVerification, OFFICIAL_CAS_REUSE_REFERENCES_FILE,
|
||||||
|
OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||||
};
|
};
|
||||||
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
||||||
pub use official_launcher::{
|
pub use official_launcher::{
|
||||||
|
|||||||
@@ -2325,8 +2325,16 @@ fn verify_localized_distribution_manifest_at(
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
anyhow::anyhow!("localized distribution 缺少 source official manifest")
|
anyhow::anyhow!("localized distribution 缺少 source official manifest")
|
||||||
})?;
|
})?;
|
||||||
let source_mapping_identity =
|
let official_anchor =
|
||||||
crate::official_download::official_distribution_mapping_identity(&official_manifest);
|
crate::official_download::verify_official_distribution_publication_at(
|
||||||
|
official_release_root,
|
||||||
|
&manifest.official_release_id,
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("localized distribution 缺少 official publication anchor")
|
||||||
|
})?;
|
||||||
|
let source_mapping_identity = official_anchor.mapping_identity.clone();
|
||||||
let expected_destination_index =
|
let expected_destination_index =
|
||||||
crate::official_download::official_distribution_destination_index(&official_manifest)
|
crate::official_download::official_distribution_destination_index(&official_manifest)
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
@@ -4196,6 +4204,11 @@ mod tests {
|
|||||||
serde_json::to_vec(&official_manifest).unwrap(),
|
serde_json::to_vec(&official_manifest).unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
crate::official_download::write_official_distribution_publication_anchor_at(
|
||||||
|
&official,
|
||||||
|
"official-v1",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let manifest = bat_patch::build_patch_manifest(
|
let manifest = bat_patch::build_patch_manifest(
|
||||||
&official,
|
&official,
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ impl std::error::Error for DownloadError {}
|
|||||||
|
|
||||||
const DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
|
const DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
|
||||||
const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
|
const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
|
||||||
|
/// Independent publication fact for the official distribution manifest.
|
||||||
|
pub const OFFICIAL_DISTRIBUTION_PUBLICATION_FILE: &str = "official-distribution-publication.json";
|
||||||
/// 记录一个已发布官方 release 获取的 CAS 引用。
|
/// 记录一个已发布官方 release 获取的 CAS 引用。
|
||||||
pub const OFFICIAL_CAS_REUSE_REFERENCES_FILE: &str = "official-cas-reuse-references.json";
|
pub const OFFICIAL_CAS_REUSE_REFERENCES_FILE: &str = "official-cas-reuse-references.json";
|
||||||
const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1;
|
const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1;
|
||||||
@@ -553,6 +555,158 @@ fn cas_has_ownership(cas_root: &Path, ownership_id: &str) -> Result<bool, String
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct OfficialDistributionPublicationAnchor {
|
||||||
|
pub(crate) version: u32,
|
||||||
|
pub(crate) official_release_id: String,
|
||||||
|
pub(crate) mapping_identity: String,
|
||||||
|
pub(crate) manifest_identity: String,
|
||||||
|
pub(crate) entry_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Writes the independent publication anchor after the complete official
|
||||||
|
/// release verification has succeeded.
|
||||||
|
///
|
||||||
|
/// The caller must invoke this only for a fully audited staging tree immediately
|
||||||
|
/// before publishing it. The function repeats the manifest/file checks so the
|
||||||
|
/// anchor cannot be created over an incomplete tree.
|
||||||
|
pub(crate) fn write_official_distribution_publication_anchor_at(
|
||||||
|
release_root: &Path,
|
||||||
|
official_release_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if official_release_id.is_empty() {
|
||||||
|
return Err("官方 distribution publication 缺少 release ID".to_string());
|
||||||
|
}
|
||||||
|
ensure_safe_directory_path(release_root, "官方 distribution publication 根目录")?;
|
||||||
|
let manifest_path = release_root.join(DOWNLOAD_MANIFEST_FILE);
|
||||||
|
let manifest_bytes = read_file_no_symlink(&manifest_path, "官方下载 manifest")?
|
||||||
|
.ok_or_else(|| format!("缺少官方下载 manifest:{}", manifest_path.display()))?;
|
||||||
|
let manifest: OfficialDownloadManifest =
|
||||||
|
serde_json::from_slice(&manifest_bytes).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"解析官方下载 manifest 失败 {}:{error}",
|
||||||
|
manifest_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if manifest.version != DOWNLOAD_MANIFEST_VERSION {
|
||||||
|
return Err(format!(
|
||||||
|
"不支持的官方下载 manifest 版本:{}",
|
||||||
|
manifest.version
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mapping_identity = official_distribution_mapping_identity(&manifest);
|
||||||
|
let expected_index = official_distribution_destination_index(&manifest)?;
|
||||||
|
if manifest.distribution_mapping_identity.as_deref() != Some(mapping_identity.as_str())
|
||||||
|
|| manifest.destination_index != expected_index
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"官方下载 manifest 未完成 distribution mapping 验证:{}",
|
||||||
|
manifest_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for entry in manifest.entries.values() {
|
||||||
|
let path = release_root.join(&entry.destination);
|
||||||
|
ensure_path_within_root(release_root, &path)?;
|
||||||
|
ensure_safe_file_target(release_root, &path, "官方 distribution 文件")?;
|
||||||
|
let bytes = fs::read(&path).map_err(|error| {
|
||||||
|
format!("读取官方 distribution 文件失败 {}:{error}", path.display())
|
||||||
|
})?;
|
||||||
|
let actual = blake3::hash(&bytes).to_hex().to_string();
|
||||||
|
if bytes.len() as u64 != entry.bytes || actual != entry.blake3 {
|
||||||
|
return Err(format!(
|
||||||
|
"官方 distribution 文件完整性失败:{}",
|
||||||
|
entry.destination
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if url_or_path_has_zip_extension(&entry.url) || path_has_zip_extension(&path) {
|
||||||
|
validate_zip_structure(&path).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"官方 distribution ZIP 结构校验失败 {}:{error}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let anchor = OfficialDistributionPublicationAnchor {
|
||||||
|
version: OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION,
|
||||||
|
official_release_id: official_release_id.to_string(),
|
||||||
|
mapping_identity,
|
||||||
|
manifest_identity: blake3::hash(&manifest_bytes).to_hex().to_string(),
|
||||||
|
entry_count: manifest.entries.len() as u64,
|
||||||
|
};
|
||||||
|
let anchor_path = release_root.join(OFFICIAL_DISTRIBUTION_PUBLICATION_FILE);
|
||||||
|
ensure_safe_file_target(release_root, &anchor_path, "官方 distribution publication")?;
|
||||||
|
let anchor_bytes = serde_json::to_vec_pretty(&anchor)
|
||||||
|
.map_err(|error| format!("序列化官方 distribution publication 失败:{error}"))?;
|
||||||
|
write_file_atomic(
|
||||||
|
&anchor_path,
|
||||||
|
&anchor_bytes,
|
||||||
|
STATE_FILE_MODE,
|
||||||
|
"官方 distribution publication",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies a published official distribution anchor without hashing resource
|
||||||
|
/// files or canonicalizing the complete mapping.
|
||||||
|
pub(crate) fn verify_official_distribution_publication_at(
|
||||||
|
release_root: &Path,
|
||||||
|
expected_release_id: &str,
|
||||||
|
) -> Result<Option<OfficialDistributionPublicationAnchor>, String> {
|
||||||
|
ensure_safe_directory_path(release_root, "官方 distribution publication 根目录")?;
|
||||||
|
let anchor_path = release_root.join(OFFICIAL_DISTRIBUTION_PUBLICATION_FILE);
|
||||||
|
let Some(anchor_bytes) = read_file_no_symlink(&anchor_path, "官方 distribution publication")?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let anchor: OfficialDistributionPublicationAnchor = serde_json::from_slice(&anchor_bytes)
|
||||||
|
.map_err(|error| format!("解析官方 distribution publication 失败:{error}"))?;
|
||||||
|
if anchor.version != OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION
|
||||||
|
|| anchor.official_release_id != expected_release_id
|
||||||
|
|| anchor.mapping_identity.is_empty()
|
||||||
|
|| anchor.manifest_identity.is_empty()
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"官方 distribution publication anchor 不一致:{}",
|
||||||
|
anchor_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let manifest_path = release_root.join(DOWNLOAD_MANIFEST_FILE);
|
||||||
|
let Some(manifest_bytes) = read_file_no_symlink(&manifest_path, "官方下载 manifest")?
|
||||||
|
else {
|
||||||
|
return Err(format!(
|
||||||
|
"官方 distribution publication 缺少 manifest:{}",
|
||||||
|
manifest_path.display()
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let manifest: OfficialDownloadManifest =
|
||||||
|
serde_json::from_slice(&manifest_bytes).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"解析官方下载 manifest 失败 {}:{error}",
|
||||||
|
manifest_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if manifest.version != DOWNLOAD_MANIFEST_VERSION {
|
||||||
|
return Err(format!(
|
||||||
|
"不支持的官方下载 manifest 版本:{}",
|
||||||
|
manifest.version
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if manifest.distribution_mapping_identity.as_deref() != Some(anchor.mapping_identity.as_str())
|
||||||
|
|| blake3::hash(&manifest_bytes).to_hex().to_string() != anchor.manifest_identity
|
||||||
|
|| manifest.entries.len() as u64 != anchor.entry_count
|
||||||
|
|| manifest.destination_index.len() as u64 != anchor.entry_count
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"官方 distribution publication 与 manifest 不一致:{}",
|
||||||
|
release_root.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(anchor))
|
||||||
|
}
|
||||||
|
|
||||||
fn legacy_source_mapping_identity(release_root: &Path) -> Result<String, String> {
|
fn legacy_source_mapping_identity(release_root: &Path) -> Result<String, String> {
|
||||||
let Some(manifest) = read_download_manifest_at(release_root)? else {
|
let Some(manifest) = read_download_manifest_at(release_root)? else {
|
||||||
return Ok("missing-official-manifest".to_string());
|
return Ok("missing-official-manifest".to_string());
|
||||||
@@ -602,11 +756,11 @@ fn resolve_legacy_ownership(
|
|||||||
return Ok((release_id, true));
|
return Ok((release_id, true));
|
||||||
}
|
}
|
||||||
if cas_has_ownership(cas_root, &release_id)? {
|
if cas_has_ownership(cas_root, &release_id)? {
|
||||||
scope
|
return Err(format!(
|
||||||
.legacy_basename_compatibility
|
"ambiguous_legacy_ownership: CAS basename ledger {} 无法证明属于 output root {};请先显式记录 compatibility marker",
|
||||||
.insert(release_id.clone());
|
release_id,
|
||||||
write_owner_scope_state(release_root, &scope)?;
|
ownership_scope_root(release_root).display()
|
||||||
return Ok((release_id, true));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let source_identity = legacy_source_mapping_identity(release_root)?;
|
let source_identity = legacy_source_mapping_identity(release_root)?;
|
||||||
@@ -5088,6 +5242,11 @@ exit 22
|
|||||||
let object_id = store_cas_object(&cas_root, b"legacy object");
|
let object_id = store_cas_object(&cas_root, b"legacy object");
|
||||||
let release_root = temp.path().join("legacy-release");
|
let release_root = temp.path().join("legacy-release");
|
||||||
fs::create_dir_all(&release_root).unwrap();
|
fs::create_dir_all(&release_root).unwrap();
|
||||||
|
let mut scope = load_owner_scope_state(&release_root).unwrap();
|
||||||
|
scope
|
||||||
|
.legacy_basename_compatibility
|
||||||
|
.insert("legacy-release".to_string());
|
||||||
|
write_owner_scope_state(&release_root, &scope).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
release_root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE),
|
release_root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE),
|
||||||
serde_json::to_vec(&OfficialCasReuseReferenceManifest {
|
serde_json::to_vec(&OfficialCasReuseReferenceManifest {
|
||||||
@@ -5112,6 +5271,61 @@ exit 22
|
|||||||
assert_eq!(cas_reference_count(&cas_root, &object_id), 1);
|
assert_eq!(cas_reference_count(&cas_root, &object_id), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_basename_ledger_without_root_marker_is_ambiguous() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let cas_root = temp.path().join("cas");
|
||||||
|
let output_a = temp.path().join("output-a/versions/release-x");
|
||||||
|
let output_b = temp.path().join("output-b/versions/release-x");
|
||||||
|
fs::create_dir_all(&output_a).unwrap();
|
||||||
|
fs::create_dir_all(&output_b).unwrap();
|
||||||
|
let object_id = store_cas_object(&cas_root, b"shared legacy object");
|
||||||
|
let cas = crate::FileSystemCasRepository::new(&cas_root);
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(async {
|
||||||
|
cas.add_reference(&object_id).await.unwrap();
|
||||||
|
cas.add_reference(&object_id).await.unwrap();
|
||||||
|
assert!(cas
|
||||||
|
.release_reference_once("release-x", 0, &object_id)
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
});
|
||||||
|
let mut output_a_scope = load_owner_scope_state(&output_a).unwrap();
|
||||||
|
output_a_scope
|
||||||
|
.legacy_basename_compatibility
|
||||||
|
.insert("release-x".to_string());
|
||||||
|
write_owner_scope_state(&output_a, &output_a_scope).unwrap();
|
||||||
|
let manifest_path = output_b.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE);
|
||||||
|
let manifest_bytes = serde_json::to_vec(&OfficialCasReuseReferenceManifest {
|
||||||
|
version: OFFICIAL_CAS_REUSE_REFERENCES_VERSION,
|
||||||
|
ownership_id: None,
|
||||||
|
object_ids: vec![object_id.clone()],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
fs::write(&manifest_path, &manifest_bytes).unwrap();
|
||||||
|
|
||||||
|
let result = release_cas_reuse_references(&output_b, &cas_root);
|
||||||
|
|
||||||
|
let error = result.unwrap_err();
|
||||||
|
assert!(error.contains("ambiguous_legacy_ownership"));
|
||||||
|
assert_eq!(cas_reference_count(&cas_root, &object_id), 2);
|
||||||
|
let ledger_exists = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(async { cas.has_release_ownership("release-x").await.unwrap() });
|
||||||
|
assert!(ledger_exists);
|
||||||
|
assert_eq!(fs::read(&manifest_path).unwrap(), manifest_bytes);
|
||||||
|
assert!(output_b.is_dir());
|
||||||
|
let output_b_scope = load_owner_scope_state(&output_b).unwrap();
|
||||||
|
assert!(!output_b_scope
|
||||||
|
.legacy_basename_compatibility
|
||||||
|
.contains("release-x"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_manifest_migration_persists_identity_before_retry() {
|
fn legacy_manifest_migration_persists_identity_before_retry() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
@@ -5161,6 +5375,11 @@ exit 22
|
|||||||
let cas_root = temp.path().join("cas");
|
let cas_root = temp.path().join("cas");
|
||||||
let first = temp.path().join("versions").join("release-x");
|
let first = temp.path().join("versions").join("release-x");
|
||||||
fs::create_dir_all(&first).unwrap();
|
fs::create_dir_all(&first).unwrap();
|
||||||
|
let mut scope = load_owner_scope_state(&first).unwrap();
|
||||||
|
scope
|
||||||
|
.legacy_basename_compatibility
|
||||||
|
.insert("release-x".to_string());
|
||||||
|
write_owner_scope_state(&first, &scope).unwrap();
|
||||||
let first_object = store_cas_object(&cas_root, b"legacy-first");
|
let first_object = store_cas_object(&cas_root, b"legacy-first");
|
||||||
let second_object = store_cas_object(&cas_root, b"legacy-second");
|
let second_object = store_cas_object(&cas_root, b"legacy-second");
|
||||||
let cas = crate::FileSystemCasRepository::new(&cas_root);
|
let cas = crate::FileSystemCasRepository::new(&cas_root);
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ use crate::official_changes::{
|
|||||||
write_official_resource_change_handoff, OfficialResourceChangeHandoffReport,
|
write_official_resource_change_handoff, OfficialResourceChangeHandoffReport,
|
||||||
OfficialResourceChangeSummary,
|
OfficialResourceChangeSummary,
|
||||||
};
|
};
|
||||||
use crate::official_download::OFFICIAL_CAS_REUSE_REFERENCES_FILE;
|
use crate::official_download::{
|
||||||
|
write_official_distribution_publication_anchor_at, OFFICIAL_CAS_REUSE_REFERENCES_FILE,
|
||||||
|
OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||||
|
};
|
||||||
use crate::official_game_main_config::{
|
use crate::official_game_main_config::{
|
||||||
resolve_game_main_config_source, OfficialGameMainConfigSelectedSource,
|
resolve_game_main_config_source, OfficialGameMainConfigSelectedSource,
|
||||||
OfficialGameMainConfigSourceKind,
|
OfficialGameMainConfigSourceKind,
|
||||||
@@ -2003,6 +2006,15 @@ impl OfficialUpdateService {
|
|||||||
"audit",
|
"audit",
|
||||||
verification_progress_message(&final_verification_summary),
|
verification_progress_message(&final_verification_summary),
|
||||||
));
|
));
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"publish",
|
||||||
|
"写入官方 distribution publication anchor",
|
||||||
|
));
|
||||||
|
write_official_distribution_publication_anchor_at(
|
||||||
|
&publish_plan.staging_path,
|
||||||
|
&publish_plan.id,
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"snapshot",
|
"snapshot",
|
||||||
format!("写入快照 {}", staging_snapshot_path.display()),
|
format!("写入快照 {}", staging_snapshot_path.display()),
|
||||||
@@ -3210,6 +3222,7 @@ fn copy_tree_no_symlink(
|
|||||||
| OFFICIAL_CURRENT_LINK
|
| OFFICIAL_CURRENT_LINK
|
||||||
| ".official-sync.lock"
|
| ".official-sync.lock"
|
||||||
| ".cas-owner-scope"
|
| ".cas-owner-scope"
|
||||||
|
| OFFICIAL_DISTRIBUTION_PUBLICATION_FILE
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ use crate::localized_patch::{
|
|||||||
};
|
};
|
||||||
use crate::official_download::{
|
use crate::official_download::{
|
||||||
read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references,
|
read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references,
|
||||||
OfficialDownloadManifest, OfficialDownloadManifestEntry,
|
verify_official_distribution_publication_at, OfficialDownloadManifest,
|
||||||
|
OfficialDownloadManifestEntry,
|
||||||
};
|
};
|
||||||
use crate::official_update::{read_version_state, OfficialVersionRecord, OfficialVersionState};
|
use crate::official_update::{read_version_state, OfficialVersionRecord, OfficialVersionState};
|
||||||
use crate::path_security::{
|
use crate::path_security::{
|
||||||
@@ -466,11 +467,14 @@ fn select_release_distribution_metadata(
|
|||||||
let manifest = read_download_manifest_at(&path)
|
let manifest = read_download_manifest_at(&path)
|
||||||
.map_err(anyhow::Error::msg)?
|
.map_err(anyhow::Error::msg)?
|
||||||
.ok_or_else(|| anyhow::anyhow!("official release 缺少官方下载 manifest"))?;
|
.ok_or_else(|| anyhow::anyhow!("official release 缺少官方下载 manifest"))?;
|
||||||
if manifest.distribution_mapping_identity.is_none()
|
let Some(_publication_anchor) =
|
||||||
|| manifest.destination_index.len() != manifest.entries.len()
|
(match verify_official_distribution_publication_at(&path, id) {
|
||||||
{
|
Ok(anchor) => anchor,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
})
|
||||||
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
};
|
||||||
let entries = if let Some(destination) = destination {
|
let entries = if let Some(destination) = destination {
|
||||||
let Some(url) = manifest.destination_index.get(destination) else {
|
let Some(url) = manifest.destination_index.get(destination) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -536,12 +540,16 @@ fn select_release_distribution_metadata(
|
|||||||
}) else {
|
}) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let Some(source_mapping_identity) =
|
let Some(official_anchor) = (match verify_official_distribution_publication_at(
|
||||||
official_manifest.distribution_mapping_identity.as_deref()
|
&source_path,
|
||||||
else {
|
&manifest.official_release_id,
|
||||||
|
) {
|
||||||
|
Ok(anchor) => anchor,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
}) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
if manifest.source_mapping_identity != source_mapping_identity
|
if manifest.source_mapping_identity != official_anchor.mapping_identity
|
||||||
|| manifest.localized_mapping_identity.is_empty()
|
|| manifest.localized_mapping_identity.is_empty()
|
||||||
|| manifest.destination_index.len() != manifest.entries.len()
|
|| manifest.destination_index.len() != manifest.entries.len()
|
||||||
|| official_manifest.destination_index.len() != official_manifest.entries.len()
|
|| official_manifest.destination_index.len() != official_manifest.entries.len()
|
||||||
@@ -1164,6 +1172,7 @@ fn list_official_releases(
|
|||||||
} else {
|
} else {
|
||||||
artifact
|
artifact
|
||||||
};
|
};
|
||||||
|
let legacy_distribution = distribution_integrity_status == "legacy";
|
||||||
releases.push(ReleaseSummary {
|
releases.push(ReleaseSummary {
|
||||||
channel: "official".to_string(),
|
channel: "official".to_string(),
|
||||||
id,
|
id,
|
||||||
@@ -1179,7 +1188,8 @@ fn list_official_releases(
|
|||||||
referenced,
|
referenced,
|
||||||
unknown: manifest_contract_status == "missing"
|
unknown: manifest_contract_status == "missing"
|
||||||
|| artifact_integrity_status == "unknown"
|
|| artifact_integrity_status == "unknown"
|
||||||
|| distribution_integrity_status == "unknown",
|
|| distribution_integrity_status == "unknown"
|
||||||
|
|| distribution_integrity_status == "legacy",
|
||||||
lifecycle: if current {
|
lifecycle: if current {
|
||||||
if artifact_valid && pointer_valid {
|
if artifact_valid && pointer_valid {
|
||||||
"current".to_string()
|
"current".to_string()
|
||||||
@@ -1194,7 +1204,7 @@ fn list_official_releases(
|
|||||||
manifest_contract_status,
|
manifest_contract_status,
|
||||||
artifact_integrity_status,
|
artifact_integrity_status,
|
||||||
distribution_integrity_status,
|
distribution_integrity_status,
|
||||||
legacy: false,
|
legacy: legacy_distribution,
|
||||||
rollback_previous_release_id,
|
rollback_previous_release_id,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
});
|
});
|
||||||
@@ -1383,16 +1393,34 @@ fn verify_official_release(path: &Path) -> ((String, String, String), Vec<String
|
|||||||
diagnostics,
|
diagnostics,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
if let Err(error) = verify_download_manifest(path, &manifest) {
|
let artifact_valid = match verify_download_manifest(path, &manifest) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(error) => {
|
||||||
diagnostics.push(error.to_string());
|
diagnostics.push(error.to_string());
|
||||||
|
false
|
||||||
}
|
}
|
||||||
let status = if diagnostics.is_empty() {
|
|
||||||
"valid"
|
|
||||||
} else {
|
|
||||||
"invalid"
|
|
||||||
};
|
};
|
||||||
|
let expected_release_id = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let distribution_integrity_status =
|
||||||
|
match verify_official_distribution_publication_at(path, expected_release_id) {
|
||||||
|
Ok(Some(_)) if artifact_valid => "valid",
|
||||||
|
Ok(None) if artifact_valid => "legacy",
|
||||||
|
Ok(Some(_)) | Ok(None) => "invalid",
|
||||||
|
Err(error) => {
|
||||||
|
diagnostics.push(error);
|
||||||
|
"invalid"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let status = if artifact_valid { "valid" } else { "invalid" };
|
||||||
(
|
(
|
||||||
("valid".to_string(), status.to_string(), status.to_string()),
|
(
|
||||||
|
"valid".to_string(),
|
||||||
|
status.to_string(),
|
||||||
|
distribution_integrity_status.to_string(),
|
||||||
|
),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1401,21 +1429,6 @@ fn verify_download_manifest(
|
|||||||
root: &Path,
|
root: &Path,
|
||||||
manifest: &OfficialDownloadManifest,
|
manifest: &OfficialDownloadManifest,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let expected_identity =
|
|
||||||
crate::official_download::official_distribution_mapping_identity(manifest);
|
|
||||||
if manifest.distribution_mapping_identity.as_deref() != Some(expected_identity.as_str()) {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"official distribution mapping identity mismatch"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let expected_index =
|
|
||||||
crate::official_download::official_distribution_destination_index(manifest)
|
|
||||||
.map_err(anyhow::Error::msg)?;
|
|
||||||
if manifest.destination_index != expected_index {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"official distribution destination index mismatch"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for entry in manifest.entries.values() {
|
for entry in manifest.entries.values() {
|
||||||
let path = safe_manifest_file_path(root, &entry.destination)?;
|
let path = safe_manifest_file_path(root, &entry.destination)?;
|
||||||
let bytes = fs::read(&path)?;
|
let bytes = fs::read(&path)?;
|
||||||
@@ -1434,6 +1447,14 @@ fn verify_full_distribution(official_root: &Path, localized_root: &Path) -> Stri
|
|||||||
let Ok(Some(manifest)) = read_download_manifest_at(official_root) else {
|
let Ok(Some(manifest)) = read_download_manifest_at(official_root) else {
|
||||||
return "unknown".to_string();
|
return "unknown".to_string();
|
||||||
};
|
};
|
||||||
|
let Some(official_release_id) = official_root.file_name().and_then(|name| name.to_str()) else {
|
||||||
|
return "unknown".to_string();
|
||||||
|
};
|
||||||
|
match verify_official_distribution_publication_at(official_root, official_release_id) {
|
||||||
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => return "legacy".to_string(),
|
||||||
|
Err(_) => return "invalid".to_string(),
|
||||||
|
}
|
||||||
if let Some(localized_release_id) = read_localized_patch_manifest_at(localized_root)
|
if let Some(localized_release_id) = read_localized_patch_manifest_at(localized_root)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
@@ -1578,10 +1599,13 @@ mod tests {
|
|||||||
let version = root.join(OFFICIAL_VERSIONS_DIR).join(id);
|
let version = root.join(OFFICIAL_VERSIONS_DIR).join(id);
|
||||||
fs::create_dir_all(&version).unwrap();
|
fs::create_dir_all(&version).unwrap();
|
||||||
let data = b"official";
|
let data = b"official";
|
||||||
|
let other = b"official-other";
|
||||||
fs::write(version.join("data.bin"), data).unwrap();
|
fs::write(version.join("data.bin"), data).unwrap();
|
||||||
|
fs::write(version.join("other.bin"), other).unwrap();
|
||||||
let mut manifest = OfficialDownloadManifest {
|
let mut manifest = OfficialDownloadManifest {
|
||||||
version: 1,
|
version: 1,
|
||||||
entries: [(
|
entries: [
|
||||||
|
(
|
||||||
"https://example.invalid/data.bin".to_string(),
|
"https://example.invalid/data.bin".to_string(),
|
||||||
OfficialDownloadManifestEntry {
|
OfficialDownloadManifestEntry {
|
||||||
url: "https://example.invalid/data.bin".to_string(),
|
url: "https://example.invalid/data.bin".to_string(),
|
||||||
@@ -1589,7 +1613,17 @@ mod tests {
|
|||||||
bytes: data.len() as u64,
|
bytes: data.len() as u64,
|
||||||
blake3: blake3::hash(data).to_hex().to_string(),
|
blake3: blake3::hash(data).to_hex().to_string(),
|
||||||
},
|
},
|
||||||
)]
|
),
|
||||||
|
(
|
||||||
|
"https://example.invalid/other.bin".to_string(),
|
||||||
|
OfficialDownloadManifestEntry {
|
||||||
|
url: "https://example.invalid/other.bin".to_string(),
|
||||||
|
destination: "other.bin".to_string(),
|
||||||
|
bytes: other.len() as u64,
|
||||||
|
blake3: blake3::hash(other).to_hex().to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect(),
|
.collect(),
|
||||||
destination_index: BTreeMap::new(),
|
destination_index: BTreeMap::new(),
|
||||||
@@ -1604,6 +1638,8 @@ mod tests {
|
|||||||
serde_json::to_vec(&manifest).unwrap(),
|
serde_json::to_vec(&manifest).unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
crate::official_download::write_official_distribution_publication_anchor_at(&version, id)
|
||||||
|
.unwrap();
|
||||||
version
|
version
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1661,12 +1697,31 @@ mod tests {
|
|||||||
assert!(!localized.unknown);
|
assert!(!localized.unknown);
|
||||||
assert_eq!(report.releases.len(), 2);
|
assert_eq!(report.releases.len(), 2);
|
||||||
|
|
||||||
|
fs::remove_file(official_version.join(crate::OFFICIAL_DISTRIBUTION_PUBLICATION_FILE))
|
||||||
|
.unwrap();
|
||||||
|
let missing_publication = select_release_distribution(
|
||||||
|
&official_root,
|
||||||
|
&localized_root,
|
||||||
|
&ReleaseDistributionParams {
|
||||||
|
channel: Some("official".to_string()),
|
||||||
|
..ReleaseDistributionParams::default()
|
||||||
|
},
|
||||||
|
Path::new("unzip"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!missing_publication.available);
|
||||||
|
crate::official_download::write_official_distribution_publication_anchor_at(
|
||||||
|
&official_version,
|
||||||
|
"official-v1",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let mut tampered = crate::read_download_manifest_at(&official_version)
|
let mut tampered = crate::read_download_manifest_at(&official_version)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
tampered
|
tampered
|
||||||
.entries
|
.entries
|
||||||
.get_mut("https://example.invalid/data.bin")
|
.get_mut("https://example.invalid/other.bin")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.url = "https://example.invalid/tampered.bin".to_string();
|
.url = "https://example.invalid/tampered.bin".to_string();
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -1696,6 +1751,18 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!blocked.available);
|
assert!(!blocked.available);
|
||||||
|
let official_blocked = select_release_distribution(
|
||||||
|
&official_root,
|
||||||
|
&localized_root,
|
||||||
|
&ReleaseDistributionParams {
|
||||||
|
channel: Some("official".to_string()),
|
||||||
|
destination: Some("data.bin".to_string()),
|
||||||
|
..ReleaseDistributionParams::default()
|
||||||
|
},
|
||||||
|
Path::new("unzip"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!official_blocked.available);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1762,11 +1829,7 @@ mod tests {
|
|||||||
url: format!("https://example.invalid/{destination}"),
|
url: format!("https://example.invalid/{destination}"),
|
||||||
destination,
|
destination,
|
||||||
bytes: data.len() as u64,
|
bytes: data.len() as u64,
|
||||||
blake3: if index == 0 {
|
blake3: blake3::hash(&data).to_hex().to_string(),
|
||||||
blake3::hash(&data).to_hex().to_string()
|
|
||||||
} else {
|
|
||||||
"0".repeat(64)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1785,6 +1848,11 @@ mod tests {
|
|||||||
serde_json::to_vec(&manifest).unwrap(),
|
serde_json::to_vec(&manifest).unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
crate::official_download::write_official_distribution_publication_anchor_at(
|
||||||
|
&version, "large",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(version.join("resource-0001.bin"), b"tampered").unwrap();
|
||||||
fs::create_dir_all(&official_root).unwrap();
|
fs::create_dir_all(&official_root).unwrap();
|
||||||
symlink(
|
symlink(
|
||||||
Path::new(OFFICIAL_VERSIONS_DIR).join("large"),
|
Path::new(OFFICIAL_VERSIONS_DIR).join("large"),
|
||||||
|
|||||||
Reference in New Issue
Block a user