mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 06:35:16 +08:00
fix(official-sync): 复用 staging 异常时退化为全量重下而非中止
recoverable_staging_path 对「staging 存在但不可用」(被外部替换成文件/symlink、 路径组件含 symlink、id 不匹配、路径逃逸等)一律返回 Err,经调用方 ? 中止整轮 同步。失败记录残留时每轮都会撞到同一异常而永久失败、无法自愈。 改为返回 Option<PathBuf>:任何无法安全复用的情况返回 None,走全量重下(始终安全 的 fail-closed 回退,复用文件仍逐一校验,不会发布损坏内容)。新增单测:staging 被替换成普通文件时不复用、返回 None 而非 Err。 对应 issue #18 维护清单 2-6。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2100,7 +2100,7 @@ fn recoverable_failed_staging(
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(staging_path) = recoverable_staging_path(record, layout)? else {
|
||||
let Some(staging_path) = recoverable_staging_path(record, layout) else {
|
||||
continue;
|
||||
};
|
||||
let mut recovered = record.clone();
|
||||
@@ -2117,51 +2117,40 @@ fn recoverable_failed_staging(
|
||||
fn recoverable_staging_path(
|
||||
record: &OfficialVersionRecord,
|
||||
layout: &OfficialPublishLayout,
|
||||
) -> Result<Option<PathBuf>, String> {
|
||||
) -> Option<PathBuf> {
|
||||
// 任何“无法安全复用”的情况都返回 None(走全量重下),而非 Err 中止整轮同步:
|
||||
// 失败记录残留时,若这里返回 Err,每轮都会撞到同一异常而永久失败、无法自愈。
|
||||
// 全量重下是始终安全的 fail-closed 回退(复用文件仍逐一校验,不会发布损坏内容)。
|
||||
let staging_path = record
|
||||
.staging_path
|
||||
.as_ref()
|
||||
.unwrap_or(&record.resource_root);
|
||||
ensure_path_within_root(&layout.staging_dir, staging_path)?;
|
||||
if ensure_path_within_root(&layout.staging_dir, staging_path).is_err() {
|
||||
return None;
|
||||
}
|
||||
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()
|
||||
));
|
||||
return None;
|
||||
}
|
||||
|
||||
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()
|
||||
));
|
||||
// staging 被外部替换成文件/symlink,或路径组件含 symlink,或读元数据出错:均不复用。
|
||||
let metadata = fs::symlink_metadata(staging_path).ok()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
return Err(format!(
|
||||
"可恢复 staging 已存在但不是目录:{}",
|
||||
staging_path.display()
|
||||
));
|
||||
if ensure_safe_directory_path(staging_path, "可恢复 staging 目录").is_err() {
|
||||
return None;
|
||||
}
|
||||
ensure_safe_directory_path(staging_path, "可恢复 staging 目录")?;
|
||||
|
||||
// 目标版本已发布,或无法确认是否已发布:都不复用该 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);
|
||||
if ensure_path_within_root(&layout.versions_dir, &version_path).is_err() {
|
||||
return None;
|
||||
}
|
||||
if path_exists_no_follow(&version_path).unwrap_or(true) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Ok(Some(staging_path.to_path_buf()))
|
||||
Some(staging_path.to_path_buf())
|
||||
}
|
||||
|
||||
fn prepare_in_progress_version_state(
|
||||
@@ -3246,6 +3235,49 @@ mod tests {
|
||||
assert_eq!(plan.version_path, temp.path().join("versions/reuse-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recoverable_failed_staging_skips_when_staging_is_not_a_directory() {
|
||||
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()),
|
||||
);
|
||||
// staging 被外部替换成普通文件而非目录。
|
||||
let staging = temp.path().join(".staging/reuse-id");
|
||||
fs::create_dir_all(staging.parent().unwrap()).unwrap();
|
||||
fs::write(&staging, b"not a directory").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();
|
||||
|
||||
// 不复用该 staging,返回 None(走全量重下)而非 Err 中止整轮同步。
|
||||
let recovered = recoverable_failed_staging(&state_path, &snapshot, &layout).unwrap();
|
||||
assert!(recovered.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recoverable_failed_staging_ignores_mismatched_snapshot() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user