From f0fbe6542955b5e1ae287027eb17a3760941e685 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Thu, 16 Jul 2026 08:08:12 -0700 Subject: [PATCH] =?UTF-8?q?fix(official-sync):=20=E5=A4=8D=E7=94=A8=20stag?= =?UTF-8?q?ing=20=E5=BC=82=E5=B8=B8=E6=97=B6=E9=80=80=E5=8C=96=E4=B8=BA?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E9=87=8D=E4=B8=8B=E8=80=8C=E9=9D=9E=E4=B8=AD?= =?UTF-8?q?=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recoverable_staging_path 对「staging 存在但不可用」(被外部替换成文件/symlink、 路径组件含 symlink、id 不匹配、路径逃逸等)一律返回 Err,经调用方 ? 中止整轮 同步。失败记录残留时每轮都会撞到同一异常而永久失败、无法自愈。 改为返回 Option:任何无法安全复用的情况返回 None,走全量重下(始终安全 的 fail-closed 回退,复用文件仍逐一校验,不会发布损坏内容)。新增单测:staging 被替换成普通文件时不复用、返回 None 而非 Err。 对应 issue #18 维护清单 2-6。 Co-Authored-By: Claude Fable 5 --- infrastructure/src/official_update.rs | 98 ++++++++++++++++++--------- 1 file changed, 65 insertions(+), 33 deletions(-) diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index deaa470..3c4fff3 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -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, String> { +) -> Option { + // 任何“无法安全复用”的情况都返回 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();