mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:15:14 +08:00
fix(official-sync): 复用失败 staging 避免重复全量下载
This commit is contained in:
+3
-3
@@ -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 <URL>` 显式指定代理,也可用 `--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/<id>` 和 `.staging/<id>`,非 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/<id>` 和 `.staging/<id>`,非 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. `<output>/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`。
|
||||
|
||||
待完成:
|
||||
|
||||
@@ -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 <URL>` 显式指定代理,或用 `--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 的失败记录,且 `<output>/.staging/<id>` 仍安全存在、`versions/<id>` 尚未发布时才会复用;复用后仍按 manifest、BLAKE3、ZIP 结构和官方 `.hash` 逐 URL 校验,不信任散落文件。
|
||||
13. curl 默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量,保留 `NO_PROXY`;CLI 可用 `--proxy <URL>` 显式指定代理,或用 `--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. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件。
|
||||
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。
|
||||
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot。
|
||||
12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/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 行为
|
||||
|
||||
@@ -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 但未发布成功,下一轮会优先查找 `<output>/official-version-state.json` 中同一 app version、bundle version 和 Addressables root 的失败版本;只有对应 `<output>/.staging/<id>` 仍存在、路径安全且 `versions/<id>` 尚未发布时,才复用该 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 会维护 `<output>/official-version-state.json`:开始下载后写入 `in_progress_version`,发布成功后写入 `current_completed_version` 和 `previous_available_version`,失败或中断后写入 `failed_versions`。同一 app version、bundle version 和 Addressables root 的失败只保留最新一条;同一版本开始重新拉取或后续发布成功时会清理对应失败记录。
|
||||
- 非 dry-run 会维护 `<output>/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`。
|
||||
|
||||
@@ -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,6 +735,7 @@ impl OfficialPublishLayout {
|
||||
|
||||
if path_exists_no_follow(&plan.staging_path)? {
|
||||
ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")?;
|
||||
if !plan.reuse_existing_staging {
|
||||
fs::remove_dir_all(&plan.staging_path).map_err(|error| {
|
||||
format!(
|
||||
"清理官方资源 staging 目录失败 {}:{error}",
|
||||
@@ -723,6 +743,7 @@ impl OfficialPublishLayout {
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
if path_exists_no_follow(&plan.version_path)? {
|
||||
return Err(format!(
|
||||
@@ -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)?;
|
||||
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<Option<OfficialVersionRecord>, 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<Option<PathBuf>, 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 {
|
||||
|
||||
Reference in New Issue
Block a user