diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 8878668..0cd4b72 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -19,11 +19,11 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 3. 默认平台为 `Windows + Android`。 4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`。 5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。 -6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、403/404/5xx 分类重试、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。 +6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。 7. 支持 curl 传输层本地代理:默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量,可用 `--proxy ` 显式指定代理,也可用 `--no-proxy` 强制直连;代理决策会写入 progress log、daemon log 和 `bat doctor` 诊断输出,带认证信息的代理 URL 会脱敏。 8. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`。 9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。 -10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/` 和 `.staging/`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。 +10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/` 和 `.staging/`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。 11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 12. `/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。 13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、403/404、hash mismatch 均有离线回归 fixture。 @@ -132,7 +132,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: - `SqliteResourceRepository`。 - 官方 pull plan 构建。 - `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。 +- `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`。 待完成: diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 3b47c24..26e227d 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -132,12 +132,13 @@ 9. 存在 `.part` 临时文件时通过 `curl --continue-at -` 尝试断点续传。 10. 新下载写入 `.part`,成功并通过必要校验后原子 rename 到 staging 内最终路径;断点续传后的 `.zip` 如果结构无效,会删除 `.part` 并重新全量下载。 11. 成功下载后更新本地下载清单。 -12. curl 默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量,保留 `NO_PROXY`;CLI 可用 `--proxy ` 显式指定代理,或用 `--no-proxy` 强制直连。代理决策会进入 progress log、daemon log 和 `doctor` 诊断输出,带认证信息的代理 URL 在日志中会脱敏。 -13. curl 失败按 HTTP/网络类型分类:403/404/普通 4xx 不重试,5xx、429、DNS、连接、超时、中断和网络类错误按尝试次数重试。 -14. 单个 URL 最终失败时写入 `official-download-quarantine.json`,发出 Failed progress,并阻止发布不完整资源。 -15. 旧 launcher 包或 `resources.assets` 下载使用官方 launcher CDN 配置,primary CDN 失败后切换 official backup CDN;资源 patch host 不猜测非官方镜像。 -16. 记录最终文件大小、本次传输字节数、官方 hash 校验数和执行状态。 -17. 非官方 URL 直接拒绝。 +12. 上一轮失败或中断留下的 staging 只有在 `official-version-state.json` 中存在同一 app version、bundle version 和 Addressables root 的失败记录,且 `/.staging/` 仍安全存在、`versions/` 尚未发布时才会复用;复用后仍按 manifest、BLAKE3、ZIP 结构和官方 `.hash` 逐 URL 校验,不信任散落文件。 +13. curl 默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量,保留 `NO_PROXY`;CLI 可用 `--proxy ` 显式指定代理,或用 `--no-proxy` 强制直连。代理决策会进入 progress log、daemon log 和 `doctor` 诊断输出,带认证信息的代理 URL 在日志中会脱敏。 +14. curl 失败按 HTTP/网络类型分类:403/404/普通 4xx 不重试,5xx、429、DNS、连接、超时、中断和网络类错误按尝试次数重试。 +15. 单个 URL 最终失败时写入 `official-download-quarantine.json`,发出 Failed progress,并阻止发布不完整资源。 +16. 旧 launcher 包或 `resources.assets` 下载使用官方 launcher CDN 配置,primary CDN 失败后切换 official backup CDN;资源 patch host 不猜测非官方镜像。 +17. 记录最终文件大小、本次传输字节数、官方 hash 校验数和执行状态。 +18. 非官方 URL 直接拒绝。 路径映射时会做分段清理,并在写入前做输出目录安全校验、相对路径归属校验和现有路径组件 symlink 检查,避免把不安全路径写进输出目录或通过 symlink 跳出输出目录。 @@ -203,7 +204,7 @@ 7. 如果远端 snapshot 未变化但输出目录没有任何当前 plan 的本地资源,仍按首次运行处理并执行全量拉取。 8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。 9. 远端变化、本地 audit 发现 repair_needed,首次空目录运行,或缺少 `current` 原子发布指针时,进入下载/发布流程。 -10. 下载先写入 `/.staging/`;若已有 active release,会先 seed staging 以复用已验证文件。 +10. 下载先写入 `/.staging/`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。 11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot。 12. 将 staging rename 为 `/versions/`,再原子替换 `/current` symlink 指向该 versioned 目录。 @@ -247,7 +248,7 @@ Linux 生产路径: - `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策 - `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖代理决策、总体下载进度、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status`、`stop`、`restart`、`reload`、`logs`、`refresh --force`、`verify`、`repair`、`doctor`、`clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态 - curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN -- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,`bat status` 会暴露版本状态摘要和最近历史失败原因 +- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因 - 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类 - 离线回归样本已覆盖当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和 seed hash mismatch - `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为 diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index 1c37033..afa03e8 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -171,6 +171,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \ - `catalog_*.hash` 是 Unity Addressables/SBP 写出的 catalog 变更标记,来源是 `HashingMethods.Calculate(json/bin catalog)` 生成的 `Hash128` 文本;它不是 seed catalog 的 `xxHash32` 规则,目前不作为 zip/JSON 内容强校验 - 存在 `.part` 临时文件时会尝试断点续传 - 新下载先写 `.part`,成功并通过必要校验后再替换为最终文件;如果断点续传后的 `.zip` 结构校验失败,会删除 `.part` 并重新全量下载 +- 如果上一轮非 dry-run 已进入 staging 但未发布成功,下一轮会优先查找 `/official-version-state.json` 中同一 app version、bundle version 和 Addressables root 的失败版本;只有对应 `/.staging/` 仍存在、路径安全且 `versions/` 尚未发布时,才复用该 staging,并继续按 manifest 校验复用或重下单个 URL - 把结果发布到 `--output/current` ## 5. 自动更新检查 @@ -187,10 +188,11 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \ - curl 失败会按类型分类:403/404/普通 4xx 不重试,5xx、429、DNS、连接、超时、中断和网络类错误按尝试次数重试。 - 单个 URL 最终失败后会写入 `official-download-quarantine.json`,progress log、daemon status 和 `bat-events.jsonl` 会记录失败类型、HTTP 状态、是否可重试、尝试次数和 quarantine 状态。 - quarantine 项会跳过本轮发布并让同步失败,避免把不完整 staging 发布到 `current`;下一轮 repair/refresh 成功后会清理对应 quarantine 条目。 +- 失败或中断后的 staging 不会无条件丢弃:如果 version-state 记录的失败版本和本轮远端元数据匹配,且 staging 目录仍安全存在,下一轮会复用该 staging;已通过 manifest 校验的文件会跳过,缺失、损坏、无 manifest 或官方 seed `.hash` 需要刷新的 URL 会重新下载。 - 旧 launcher 包或 `resources.assets` 下载路径使用官方 launcher CDN 配置,primary CDN 失败后会切换官方 backup CDN;资源 patch host 当前只使用 server-info 返回的官方 client-patch host,不猜测非官方镜像。 - 远端和本地都一致:单次模式输出 `update_status=up_to_date`,watch 模式默认静默并等待下次检查。 - 有远端变化或本地 repair:生成 pull plan,下载完整官方资源到 staging,成功后更新 snapshot 并原子发布到 `current`。 -- 非 dry-run 会维护 `/official-version-state.json`:开始下载后写入 `in_progress_version`,发布成功后写入 `current_completed_version` 和 `previous_available_version`,失败或中断后写入 `failed_versions`。同一 app version、bundle version 和 Addressables root 的失败只保留最新一条;同一版本开始重新拉取或后续发布成功时会清理对应失败记录。 +- 非 dry-run 会维护 `/official-version-state.json`:开始下载后写入 `in_progress_version`,发布成功后写入 `current_completed_version` 和 `previous_available_version`,失败或中断后写入 `failed_versions`。同一 app version、bundle version 和 Addressables root 的失败只保留最新一条;同一版本开始重新拉取或后续发布成功时会清理对应失败记录。重新拉取同一失败版本时会复用安全存在的失败 staging,不会因为 `publish_id` 变化从空目录重新开始。 - `--dry-run`:只报告本次是否会下载,不写 snapshot;如果 cache miss,也不会写入新的 bootstrap cache。 - `--dry-run --plan`:除更新判断外,还会解析 seed catalog 并打印完整下载 URL。 - 真实更新会输出 `downloaded_count`、`resumed_count`、`skipped_count`、`transferred_bytes`、`official_seed_hash_verified_count`。 diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index c3b327c..fdc50d8 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -632,6 +632,7 @@ struct OfficialPublishPlan { id: String, staging_path: PathBuf, version_path: PathBuf, + reuse_existing_staging: bool, } impl OfficialPublishLayout { @@ -694,12 +695,30 @@ impl OfficialPublishLayout { Ok(self.current_target()?.is_some()) } - fn plan(&self, snapshot: &OfficialUpdateSnapshot) -> OfficialPublishPlan { + fn plan( + &self, + snapshot: &OfficialUpdateSnapshot, + recovered_staging: Option<&OfficialVersionRecord>, + ) -> OfficialPublishPlan { + if let Some(record) = recovered_staging { + let staging_path = record + .staging_path + .clone() + .unwrap_or_else(|| record.resource_root.clone()); + return OfficialPublishPlan { + id: record.id.clone(), + staging_path, + version_path: self.versions_dir.join(&record.id), + reuse_existing_staging: true, + }; + } + let id = publish_id(snapshot); OfficialPublishPlan { staging_path: self.staging_dir.join(&id), version_path: self.versions_dir.join(&id), id, + reuse_existing_staging: false, } } @@ -716,12 +735,14 @@ impl OfficialPublishLayout { if path_exists_no_follow(&plan.staging_path)? { ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")?; - fs::remove_dir_all(&plan.staging_path).map_err(|error| { - format!( - "清理官方资源 staging 目录失败 {}:{error}", - plan.staging_path.display() - ) - })?; + if !plan.reuse_existing_staging { + fs::remove_dir_all(&plan.staging_path).map_err(|error| { + format!( + "清理官方资源 staging 目录失败 {}:{error}", + plan.staging_path.display() + ) + })?; + } } if path_exists_no_follow(&plan.version_path)? { @@ -731,6 +752,10 @@ impl OfficialPublishLayout { )); } + if plan.reuse_existing_staging { + return Ok(()); + } + fs::create_dir_all(&plan.staging_path).map_err(|error| { format!( "创建官方资源 staging 目录失败 {}:{error}", @@ -1236,7 +1261,28 @@ impl OfficialUpdateService { check_shutdown_requested(&mut should_cancel)?; let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len(); - let publish_plan = publish_layout.plan(¤t_update_snapshot); + let recovered_staging = recoverable_failed_staging( + &version_state_path, + ¤t_update_snapshot, + &publish_layout, + ) + .map_err(anyhow::Error::msg)?; + if let Some(record) = recovered_staging.as_ref() { + progress(OfficialUpdateProgress::new( + "publish", + format!( + "发现可复用的未完成 staging:id={} path={}", + record.id, + record + .staging_path + .as_ref() + .unwrap_or(&record.resource_root) + .display() + ), + )); + } + let publish_plan = + publish_layout.plan(¤t_update_snapshot, recovered_staging.as_ref()); progress(OfficialUpdateProgress::new( "publish", format!( @@ -1248,9 +1294,16 @@ impl OfficialUpdateService { publish_layout .prepare_staging(&publish_plan) .map_err(anyhow::Error::msg)?; - publish_layout - .seed_staging_from_active(&active_resource_root, &publish_plan.staging_path) - .map_err(anyhow::Error::msg)?; + if publish_plan.reuse_existing_staging { + progress(OfficialUpdateProgress::new( + "publish", + "复用未完成 staging;跳过 active release seed,避免覆盖已下载文件", + )); + } else { + publish_layout + .seed_staging_from_active(&active_resource_root, &publish_plan.staging_path) + .map_err(anyhow::Error::msg)?; + } let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path); let in_progress_record = prepare_in_progress_version_state( &version_state_path, @@ -1989,6 +2042,86 @@ fn recover_interrupted_version_state(path: &Path) -> anyhow::Result<()> { write_version_state(path, &state) } +fn recoverable_failed_staging( + version_state_path: &Path, + snapshot: &OfficialUpdateSnapshot, + layout: &OfficialPublishLayout, +) -> Result, String> { + let Some(state) = read_version_state(version_state_path).map_err(|error| error.to_string())? + else { + return Ok(None); + }; + + for failed in state.failed_versions.iter().rev() { + let record = &failed.version; + if !version_record_matches_snapshot(record, snapshot) { + continue; + } + + let Some(staging_path) = recoverable_staging_path(record, layout)? else { + continue; + }; + let mut recovered = record.clone(); + recovered.resource_root = staging_path.clone(); + recovered.staging_path = Some(staging_path); + recovered.version_path = None; + recovered.completed_unix_seconds = None; + return Ok(Some(recovered)); + } + + Ok(None) +} + +fn recoverable_staging_path( + record: &OfficialVersionRecord, + layout: &OfficialPublishLayout, +) -> Result, String> { + let staging_path = record + .staging_path + .as_ref() + .unwrap_or(&record.resource_root); + ensure_path_within_root(&layout.staging_dir, staging_path)?; + if version_id_from_path(staging_path).as_deref() != Some(record.id.as_str()) { + return Err(format!( + "官方版本状态中的 staging 路径和版本 ID 不匹配:id={} path={}", + record.id, + staging_path.display() + )); + } + + let metadata = match fs::symlink_metadata(staging_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "读取可恢复 staging 元数据失败 {}:{error}", + staging_path.display() + )) + } + }; + if metadata.file_type().is_symlink() { + return Err(format!( + "可恢复 staging 不能是 symlink:{}", + staging_path.display() + )); + } + if !metadata.is_dir() { + return Err(format!( + "可恢复 staging 已存在但不是目录:{}", + staging_path.display() + )); + } + ensure_safe_directory_path(staging_path, "可恢复 staging 目录")?; + + let version_path = layout.versions_dir.join(&record.id); + ensure_path_within_root(&layout.versions_dir, &version_path)?; + if path_exists_no_follow(&version_path)? { + return Ok(None); + } + + Ok(Some(staging_path.to_path_buf())) +} + fn prepare_in_progress_version_state( path: &Path, snapshot: &OfficialUpdateSnapshot, @@ -2038,6 +2171,15 @@ fn prepare_in_progress_version_state( Ok(record) } +fn version_record_matches_snapshot( + record: &OfficialVersionRecord, + snapshot: &OfficialUpdateSnapshot, +) -> bool { + record.app_version == snapshot.app_version + && record.bundle_version == snapshot.bundle_version + && record.addressables_root == snapshot.addressables_root +} + fn complete_version_state( path: &Path, record: OfficialVersionRecord, @@ -2888,6 +3030,111 @@ mod tests { assert!(state.failed_versions.is_empty()); } + #[test] + fn recoverable_failed_staging_preserves_matching_staging() { + let temp = tempfile::TempDir::new().unwrap(); + let layout = OfficialPublishLayout::new(temp.path()); + let snapshot = OfficialUpdateSnapshot::new( + fixture_base_snapshot(), + vec![fixture_marker("current")], + Some(&fixture_bootstrap()), + ); + let staging = temp.path().join(".staging/reuse-id"); + fs::create_dir_all(&staging).unwrap(); + fs::write(staging.join("kept.bin"), b"already-downloaded").unwrap(); + let record = version_record_for_snapshot( + &snapshot, + VersionRecordInput { + id: "reuse-id".to_string(), + resource_root: staging.clone(), + snapshot_path: staging.join(OFFICIAL_SYNC_SNAPSHOT_FILE), + staging_path: Some(staging.clone()), + version_path: None, + started_unix_seconds: Some(10), + completed_unix_seconds: None, + }, + ); + let state = OfficialVersionState { + failed_versions: vec![OfficialFailedVersionRecord { + version: record, + error: "download interrupted".to_string(), + failed_unix_seconds: 11, + }], + updated_unix_seconds: 11, + ..OfficialVersionState::default() + }; + let state_path = temp.path().join(OFFICIAL_VERSION_STATE_FILE); + write_version_state(&state_path, &state).unwrap(); + + let recovered = recoverable_failed_staging(&state_path, &snapshot, &layout) + .unwrap() + .unwrap(); + let plan = layout.plan(&snapshot, Some(&recovered)); + assert!(plan.reuse_existing_staging); + + layout.prepare_staging(&plan).unwrap(); + + assert_eq!( + fs::read(staging.join("kept.bin")).unwrap(), + b"already-downloaded" + ); + assert_eq!(plan.staging_path, staging); + assert_eq!(plan.version_path, temp.path().join("versions/reuse-id")); + } + + #[test] + fn recoverable_failed_staging_ignores_mismatched_snapshot() { + let temp = tempfile::TempDir::new().unwrap(); + let layout = OfficialPublishLayout::new(temp.path()); + let previous_snapshot = OfficialUpdateSnapshot::new( + fixture_base_snapshot(), + vec![fixture_marker("previous")], + Some(&fixture_bootstrap()), + ); + let mut current_base = fixture_base_snapshot(); + current_base.addressables_root = + "https://prod-clientpatch.bluearchiveyostar.com/other".to_string(); + let current_snapshot = OfficialUpdateSnapshot::new( + current_base, + vec![fixture_marker("current")], + Some(&fixture_bootstrap()), + ); + let staging = temp.path().join(".staging/old-id"); + fs::create_dir_all(&staging).unwrap(); + let record = version_record_for_snapshot( + &previous_snapshot, + VersionRecordInput { + id: "old-id".to_string(), + resource_root: staging, + snapshot_path: temp + .path() + .join(".staging/old-id") + .join(OFFICIAL_SYNC_SNAPSHOT_FILE), + staging_path: Some(temp.path().join(".staging/old-id")), + version_path: None, + started_unix_seconds: Some(10), + completed_unix_seconds: None, + }, + ); + let state = OfficialVersionState { + failed_versions: vec![OfficialFailedVersionRecord { + version: record, + error: "download interrupted".to_string(), + failed_unix_seconds: 11, + }], + updated_unix_seconds: 11, + ..OfficialVersionState::default() + }; + let state_path = temp.path().join(OFFICIAL_VERSION_STATE_FILE); + write_version_state(&state_path, &state).unwrap(); + + assert!( + recoverable_failed_staging(&state_path, ¤t_snapshot, &layout) + .unwrap() + .is_none() + ); + } + #[test] fn update_rejects_dangerous_output_root_before_network_work() { let config = OfficialUpdateConfig {