mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 10:54:55 +08:00
fix(sync): 补齐发布清理与校验进度
This commit is contained in:
@@ -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")));
|
||||
|
||||
Reference in New Issue
Block a user