fix(sync): 补齐发布清理与校验进度

This commit is contained in:
2026-07-31 13:00:53 +08:00
parent 8ec0e12795
commit 16e73327b4
4 changed files with 592 additions and 62 deletions
+358 -30
View File
@@ -91,6 +91,8 @@ pub enum OfficialResourcePullProgressKind {
Started,
/// A URL finished as skipped, resumed, or downloaded.
Finished,
/// An official sidecar hash pair passed verification.
Verification,
/// A URL failed after retry classification and was quarantined for this run.
Failed,
}
@@ -101,11 +103,29 @@ impl OfficialResourcePullProgressKind {
match self {
Self::Started => "started",
Self::Finished => "finished",
Self::Verification => "verification",
Self::Failed => "failed",
}
}
}
/// Local validation results for one completed resource.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialResourceVerification {
/// Expected byte size from the local manifest, when reusing a file.
pub expected_bytes: Option<u64>,
/// Actual byte size observed after the transfer.
pub actual_bytes: u64,
/// Expected BLAKE3 from the local manifest, when reusing a file.
pub expected_blake3: Option<String>,
/// Actual BLAKE3 computed from the completed file.
pub actual_blake3: String,
/// Whether ZIP structure validation was required for this resource.
pub zip_checked: bool,
/// Whether the required ZIP structure validation passed.
pub zip_structure_verified: bool,
}
/// Progress for one URL in an official pull plan.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialResourcePullProgress {
@@ -128,6 +148,10 @@ pub struct OfficialResourcePullProgress {
pub bytes: Option<u64>,
/// Bytes transferred during this run, when `kind` is `Finished`.
pub transferred_bytes: Option<u64>,
/// Local size/BLAKE3/ZIP validation for a completed URL.
pub verification: Option<OfficialResourceVerification>,
/// Official sidecar hash verification completed after this URL.
pub official_hash: Option<OfficialResourceHashVerification>,
/// Stable failure kind label, when `kind` is `Failed`.
pub failure_kind: Option<String>,
/// HTTP status parsed from curl stderr, when available.
@@ -150,6 +174,8 @@ impl OfficialResourcePullProgress {
status: None,
bytes: None,
transferred_bytes: None,
verification: None,
official_hash: None,
failure_kind: None,
failure_http_status: None,
failure_retryable: None,
@@ -165,6 +191,7 @@ impl OfficialResourcePullProgress {
status: OfficialResourcePullStatus,
bytes: u64,
transferred_bytes: u64,
verification: OfficialResourceVerification,
) -> Self {
Self {
kind: OfficialResourcePullProgressKind::Finished,
@@ -174,6 +201,32 @@ impl OfficialResourcePullProgress {
status: Some(status),
bytes: Some(bytes),
transferred_bytes: Some(transferred_bytes),
verification: Some(verification),
official_hash: None,
failure_kind: None,
failure_http_status: None,
failure_retryable: None,
failure_attempts: None,
quarantined: false,
}
}
fn verification(
index: usize,
total: usize,
url: String,
official_hash: OfficialResourceHashVerification,
) -> Self {
Self {
kind: OfficialResourcePullProgressKind::Verification,
index,
total,
url,
status: None,
bytes: None,
transferred_bytes: None,
verification: None,
official_hash: Some(official_hash),
failure_kind: None,
failure_http_status: None,
failure_retryable: None,
@@ -191,6 +244,8 @@ impl OfficialResourcePullProgress {
status: None,
bytes: None,
transferred_bytes: None,
verification: None,
official_hash: None,
failure_kind: error.failure_kind().map(ToOwned::to_owned),
failure_http_status: error.http_status(),
failure_retryable: error.retryable(),
@@ -201,9 +256,10 @@ impl OfficialResourcePullProgress {
}
/// Official hash algorithm used by a verified resource sidecar.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OfficialResourceHashAlgorithm {
/// Decimal text form of `xxHash32` with seed `0`.
#[serde(rename = "xxhash32_decimal")]
XxHash32Decimal,
}
@@ -217,7 +273,7 @@ impl OfficialResourceHashAlgorithm {
}
/// Successful official hash verification for one downloaded resource.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialResourceHashVerification {
/// Downloaded data URL that was verified.
pub data_url: String,
@@ -534,6 +590,40 @@ impl OfficialResourcePullService {
self.output_root.join(DOWNLOAD_QUARANTINE_FILE)
}
/// Removes resource files recorded by an older manifest but absent from
/// the current pull plan.
///
/// This is intentionally limited to destinations owned by the previous
/// download manifest. Management files such as snapshots, version state,
/// and quarantine records are left untouched.
pub fn prune_stale_manifest_entries(
&self,
plan: &OfficialResourcePullPlan,
) -> Result<usize, String> {
self.ensure_output_root_ready()?;
let expected_urls = plan.all_urls()?.into_iter().collect::<HashSet<_>>();
let mut manifest = self.read_download_manifest()?;
let stale_entries = manifest
.entries
.iter()
.filter(|(url, _)| !expected_urls.contains(*url))
.map(|(url, entry)| (url.clone(), entry.destination.clone()))
.collect::<Vec<_>>();
for (url, relative_destination) in &stale_entries {
let destination = self.output_root.join(relative_destination);
self.remove_managed_destination(&destination, url)?;
self.remove_managed_destination(&partial_path_for(&destination), url)?;
manifest.entries.remove(url);
}
if !stale_entries.is_empty() {
self.write_download_manifest(&manifest)?;
}
Ok(stale_entries.len())
}
/// Executes the plan and downloads every official URL to disk.
pub fn pull(
&self,
@@ -630,8 +720,8 @@ impl OfficialResourcePullService {
let result = self.pull_one(&item.url, &item.destination);
match result {
Ok(pull_result) => {
if let Err(error) = self
Ok(mut pull_result) => {
let verification_result = self
.clear_quarantine_entry(&item.url)
.and_then(|_| {
self.record_download_manifest_entry(
@@ -640,17 +730,25 @@ impl OfficialResourcePullService {
&item.destination,
)
})
.and_then(|_| self.write_download_manifest(&manifest))
{
let error = PullOneError::plain(format!("记录下载 manifest 失败:{error}"));
self.record_quarantine_entry(&item.url, &item.destination, &error)?;
progress(OfficialResourcePullProgress::failed(
completed_count,
total,
item.url.clone(),
&error,
));
return Err(DownloadError::new(
.and_then(|verification| {
self.write_download_manifest(&manifest)
.map(|_| verification)
});
match verification_result {
Ok(verification) => {
pull_result.verification = verification;
}
Err(error) => {
let error =
PullOneError::plain(format!("记录下载 manifest 失败:{error}"));
self.record_quarantine_entry(&item.url, &item.destination, &error)?;
progress(OfficialResourcePullProgress::failed(
completed_count,
total,
item.url.clone(),
&error,
));
return Err(DownloadError::new(
error.error_code(),
format!(
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={} quarantine={}{}",
@@ -659,6 +757,7 @@ impl OfficialResourcePullService {
error.message
),
));
}
}
completed_count += 1;
@@ -669,6 +768,7 @@ impl OfficialResourcePullService {
pull_result.status,
pull_result.bytes,
pull_result.transferred_bytes,
pull_result.verification.clone(),
));
download_results[plan_index] = Some(Ok(pull_result));
}
@@ -719,8 +819,9 @@ impl OfficialResourcePullService {
existing.status,
existing.bytes,
existing.transferred_bytes,
existing.verification.clone(),
));
*existing
existing.clone()
} else {
// Phase B 已保证需下载项此时均为 Ok(失败会在上面 fail-fast 返回)。
match download_results[plan_index].take() {
@@ -735,13 +836,21 @@ impl OfficialResourcePullService {
};
processed_urls.insert(item.url.clone());
self.verify_ready_official_hashes(
let newly_verified_hashes = self.verify_ready_official_hashes(
&official_hash_pairs,
&processed_urls,
&mut verified_hash_urls,
&mut verified_hashes,
&mut manifest,
)?;
for verification in newly_verified_hashes {
progress(OfficialResourcePullProgress::verification(
completed_count,
total,
verification.data_url.clone(),
verification,
));
}
items.push(OfficialResourcePullItem {
url: item.url.clone(),
destination: item.destination.clone(),
@@ -960,6 +1069,9 @@ impl OfficialResourcePullService {
.map_err(PullOneError::plain)?
.ok_or_else(|| format!("下载完成后目标文件缺失 {}", destination.display()))
.map_err(PullOneError::plain)?;
let verification = self
.local_verification(url, destination, None, None)
.map_err(PullOneError::plain)?;
Ok(PullOneResult {
bytes,
@@ -969,6 +1081,7 @@ impl OfficialResourcePullService {
bytes
},
status,
verification,
})
}
@@ -1010,7 +1123,8 @@ impl OfficialResourcePullService {
verified_hash_urls: &mut HashSet<String>,
verified_hashes: &mut Vec<OfficialResourceHashVerification>,
manifest: &mut OfficialDownloadManifest,
) -> Result<(), String> {
) -> Result<Vec<OfficialResourceHashVerification>, String> {
let mut newly_verified = Vec::new();
for pair in pairs {
if verified_hash_urls.contains(&pair.hash_url) {
continue;
@@ -1040,11 +1154,12 @@ impl OfficialResourcePullService {
}
};
newly_verified.push(verification.clone());
verified_hashes.push(verification);
verified_hash_urls.insert(pair.hash_url.clone());
}
Ok(())
Ok(newly_verified)
}
fn hash_pair_files_exist(&self, pair: &OfficialSeedHashPair) -> Result<bool, String> {
@@ -1124,6 +1239,41 @@ impl OfficialResourcePullService {
Ok(manifest)
}
fn remove_managed_destination(&self, path: &Path, url: &str) -> Result<(), String> {
ensure_path_within_root(&self.output_root, path)?;
ensure_safe_file_target(&self.output_root, path, "旧官方资源清理目标")?;
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(format!(
"旧官方资源清理目标不能是 symlink:url={url} path={}",
path.display()
)),
Ok(metadata) if metadata.is_file() => {
fs::remove_file(path).map_err(|error| {
format!(
"清理旧官方资源失败:url={url} path={} error={error}",
path.display()
)
})?;
remove_empty_parent_directories(&self.output_root, path.parent())?;
Ok(())
}
Ok(metadata) if metadata.is_dir() => Err(format!(
"旧官方资源清理目标不能是目录:url={url} path={}",
path.display()
)),
Ok(_) => Err(format!(
"旧官方资源清理目标不是普通文件:url={url} path={}",
path.display()
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!(
"检查旧官方资源清理目标失败:url={url} path={} error={error}",
path.display()
)),
}
}
fn write_download_manifest(&self, manifest: &OfficialDownloadManifest) -> Result<(), String> {
self.ensure_output_root_ready()?;
let path = self.download_manifest_path();
@@ -1253,23 +1403,52 @@ impl OfficialResourcePullService {
return Ok(None);
}
let verification = self.local_verification(
url,
destination,
Some(entry.bytes),
Some(entry.blake3.clone()),
)?;
Ok(Some(PullOneResult {
bytes,
transferred_bytes: 0,
status: OfficialResourcePullStatus::SkippedExisting,
verification,
}))
}
fn local_verification(
&self,
url: &str,
destination: &Path,
expected_bytes: Option<u64>,
expected_blake3: Option<String>,
) -> Result<OfficialResourceVerification, String> {
let actual_bytes = file_len_if_exists(destination)?
.ok_or_else(|| format!("读取资源文件信息失败 {}", destination.display()))?;
let actual_blake3 = blake3_file_hex(destination)?;
let zip_checked = url_or_path_has_zip_extension(url) || path_has_zip_extension(destination);
if zip_checked {
self.validate_zip_if_needed(url, destination)?;
}
Ok(OfficialResourceVerification {
expected_bytes,
actual_bytes,
expected_blake3,
actual_blake3,
zip_checked,
zip_structure_verified: zip_checked,
})
}
fn record_download_manifest_entry(
&self,
manifest: &mut OfficialDownloadManifest,
url: &str,
destination: &Path,
) -> Result<(), String> {
self.validate_zip_if_needed(url, destination)?;
let bytes = file_len_if_exists(destination)?
.ok_or_else(|| format!("读取已下载文件信息失败 {}", destination.display()))?;
let digest = blake3_file_hex(destination)?;
) -> Result<OfficialResourceVerification, String> {
let mut verification = self.local_verification(url, destination, None, None)?;
let relative_destination = self.relative_destination(destination)?;
manifest.entries.insert(
@@ -1277,12 +1456,14 @@ impl OfficialResourcePullService {
OfficialDownloadManifestEntry {
url: url.to_string(),
destination: relative_destination,
bytes,
blake3: digest,
bytes: verification.actual_bytes,
blake3: verification.actual_blake3.clone(),
},
);
Ok(())
verification.expected_bytes = Some(verification.actual_bytes);
verification.expected_blake3 = Some(verification.actual_blake3.clone());
Ok(verification)
}
fn validate_zip_if_needed(&self, url: &str, destination: &Path) -> Result<(), String> {
@@ -1703,11 +1884,12 @@ struct PlannedDownload {
existing: Option<PullOneResult>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
struct PullOneResult {
bytes: u64,
transferred_bytes: u64,
status: OfficialResourcePullStatus,
verification: OfficialResourceVerification,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -1788,6 +1970,48 @@ fn partial_path_for(destination: &Path) -> PathBuf {
PathBuf::from(partial)
}
fn remove_empty_parent_directories(root: &Path, parent: Option<&Path>) -> Result<(), String> {
let mut current = parent;
while let Some(path) = current {
if path == root {
break;
}
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
current = path.parent();
continue;
}
Err(error) => {
return Err(format!(
"检查旧官方资源父目录失败 {}{error}",
path.display()
));
}
};
if metadata.file_type().is_symlink() {
return Err(format!(
"旧官方资源父目录不能是 symlink:{}",
path.display()
));
}
if !metadata.is_dir() {
break;
}
let mut entries = fs::read_dir(path)
.map_err(|error| format!("读取旧官方资源父目录失败 {}{error}", path.display()))?;
if entries.next().is_some() {
break;
}
fs::remove_dir(path)
.map_err(|error| format!("清理旧官方资源空目录失败 {}{error}", path.display()))?;
current = path.parent();
}
Ok(())
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -2711,6 +2935,95 @@ exit 22
assert!(manifest_state.has_any_resources());
}
#[test]
fn prune_stale_manifest_entries_removes_old_resource_and_partial_only() {
let out_dir = TempDir::new().unwrap();
let service = OfficialResourcePullService::new(out_dir.path());
let plan = build_official_pull_plan(discovery_plan(), inventory());
let expected_url = plan.all_urls().unwrap().into_iter().next().unwrap();
let expected_destination = service.destination_for_url(&expected_url).unwrap();
fs::create_dir_all(expected_destination.parent().unwrap()).unwrap();
fs::write(&expected_destination, b"keep").unwrap();
let stale_url =
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/obsolete/old.bundle";
let stale_destination = service.destination_for_url(stale_url).unwrap();
fs::create_dir_all(stale_destination.parent().unwrap()).unwrap();
fs::write(&stale_destination, b"remove").unwrap();
fs::write(partial_path_for(&stale_destination), b"remove-partial").unwrap();
let mut manifest = OfficialDownloadManifest::default();
service
.record_download_manifest_entry(&mut manifest, &expected_url, &expected_destination)
.unwrap();
manifest.entries.insert(
stale_url.to_string(),
OfficialDownloadManifestEntry {
url: stale_url.to_string(),
destination: service.relative_destination(&stale_destination).unwrap(),
bytes: 6,
blake3: "stale".to_string(),
},
);
service.write_download_manifest(&manifest).unwrap();
let snapshot_path = out_dir.path().join("official-sync-snapshot.json");
let quarantine_path = out_dir.path().join(DOWNLOAD_QUARANTINE_FILE);
fs::write(&snapshot_path, b"snapshot").unwrap();
fs::write(&quarantine_path, b"quarantine").unwrap();
assert_eq!(service.prune_stale_manifest_entries(&plan).unwrap(), 1);
assert!(!stale_destination.exists());
assert!(!partial_path_for(&stale_destination).exists());
assert!(!stale_destination.parent().unwrap().exists());
assert!(expected_destination.exists());
assert_eq!(fs::read(&snapshot_path).unwrap(), b"snapshot");
assert_eq!(fs::read(&quarantine_path).unwrap(), b"quarantine");
let pruned_manifest = service.read_download_manifest().unwrap();
assert!(pruned_manifest.entries.contains_key(&expected_url));
assert!(!pruned_manifest.entries.contains_key(stale_url));
}
#[cfg(unix)]
#[test]
fn prune_stale_manifest_entries_rejects_symlink_destination() {
use std::os::unix::fs::symlink;
let out_dir = TempDir::new().unwrap();
let service = OfficialResourcePullService::new(out_dir.path());
let plan = build_official_pull_plan(discovery_plan(), inventory());
let stale_url =
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/obsolete/old.bundle";
let stale_destination = service.destination_for_url(stale_url).unwrap();
fs::create_dir_all(stale_destination.parent().unwrap()).unwrap();
let outside = out_dir.path().join("outside-resource");
fs::write(&outside, b"must-keep").unwrap();
symlink(&outside, &stale_destination).unwrap();
let mut manifest = OfficialDownloadManifest::default();
manifest.entries.insert(
stale_url.to_string(),
OfficialDownloadManifestEntry {
url: stale_url.to_string(),
destination: service.relative_destination(&stale_destination).unwrap(),
bytes: 9,
blake3: "stale".to_string(),
},
);
service.write_download_manifest(&manifest).unwrap();
let error = service.prune_stale_manifest_entries(&plan).unwrap_err();
assert!(error.contains("symlink"));
assert!(stale_destination.exists());
assert_eq!(fs::read(&outside).unwrap(), b"must-keep");
assert!(service
.read_download_manifest()
.unwrap()
.entries
.contains_key(stale_url));
}
#[test]
fn redownloads_existing_file_when_manifest_hash_mismatches() {
let out_dir = TempDir::new().unwrap();
@@ -2974,8 +3287,6 @@ exit 22
.unwrap();
assert_eq!(report.items.len(), expected_urls);
assert_eq!(events.len(), expected_urls * 2);
let started: Vec<_> = events
.iter()
.filter(|event| event.kind == OfficialResourcePullProgressKind::Started)
@@ -2984,8 +3295,13 @@ exit 22
.iter()
.filter(|event| event.kind == OfficialResourcePullProgressKind::Finished)
.collect();
let verifications: Vec<_> = events
.iter()
.filter(|event| event.kind == OfficialResourcePullProgressKind::Verification)
.collect();
assert_eq!(started.len(), expected_urls);
assert_eq!(finished.len(), expected_urls);
assert_eq!(verifications.len(), report.verified_hashes.len());
// 每个 started 的 total 一致;index 表示已完成数量,不能超过总数。
assert!(started.iter().all(|event| event.index <= expected_urls));
@@ -3001,6 +3317,18 @@ exit 22
assert!(finished
.iter()
.all(|event| event.status == Some(OfficialResourcePullStatus::Downloaded)));
assert!(finished.iter().all(|event| {
event.verification.as_ref().is_some_and(|verification| {
verification.actual_bytes > 0 && !verification.actual_blake3.is_empty()
})
}));
assert!(verifications.iter().all(|event| {
event.official_hash.as_ref().is_some_and(|hash| {
hash.algorithm == OfficialResourceHashAlgorithm::XxHash32Decimal
&& hash.expected == hash.actual
&& !hash.hash_url.is_empty()
})
}));
assert!(events
.iter()
.any(|event| event.url.ends_with("/TableBundles/ExcelDB.db")));