feat: improve bat daemon cli output

This commit is contained in:
2026-07-12 23:04:13 +08:00
parent 264e78c440
commit f7255f7c04
8 changed files with 3231 additions and 344 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ authors.workspace = true
license.workspace = true license.workspace = true
[[bin]] [[bin]]
name = "bat-official-sync" name = "bat"
path = "src/bin/bat_official_sync.rs" path = "src/bin/bat_official_sync.rs"
[dependencies] [dependencies]
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -24,9 +24,9 @@ pub use cas::FileSystemCasRepository;
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService}; pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
pub use official_download::{ pub use official_download::{
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport, OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
OfficialLocalManifestAuditStatus, OfficialResourcePullItem, OfficialResourcePullProgress, OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem,
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService, OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport,
OfficialResourcePullStatus, OfficialResourcePullService, OfficialResourcePullStatus,
}; };
pub use official_game_main_config::OfficialGameMainConfigBootstrapService; pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
pub use official_launcher::{ pub use official_launcher::{
+186 -85
View File
@@ -226,6 +226,19 @@ impl OfficialLocalManifestAuditStatus {
pub fn is_verified(self) -> bool { pub fn is_verified(self) -> bool {
self == Self::Verified self == Self::Verified
} }
/// Returns a stable status label for CLI and JSON reports.
pub fn as_str(self) -> &'static str {
match self {
Self::Verified => "verified",
Self::MissingManifestEntry => "missing_manifest_entry",
Self::UrlMismatch => "url_mismatch",
Self::DestinationMismatch => "destination_mismatch",
Self::MissingFile => "missing_file",
Self::SizeMismatch => "size_mismatch",
Self::Blake3Mismatch => "blake3_mismatch",
}
}
} }
/// Local download-manifest audit result for one expected official URL. /// Local download-manifest audit result for one expected official URL.
@@ -276,6 +289,43 @@ impl OfficialLocalManifestAuditReport {
} }
} }
/// Full local verification report for all entries currently recorded in the
/// official download manifest.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OfficialLocalVerificationReport {
/// Per-entry manifest audit results.
pub items: Vec<OfficialLocalManifestAuditItem>,
/// Official seed hash pairs found in the local manifest.
pub official_hash_pair_count: usize,
/// Official seed hash pairs that passed xxHash32 verification.
pub official_hash_verified_count: usize,
/// Official seed hash verification failures.
pub official_hash_errors: Vec<String>,
}
impl OfficialLocalVerificationReport {
/// Returns true when every local manifest entry and every local official
/// seed hash pair passed verification.
pub fn is_clean(&self) -> bool {
self.items.iter().all(|item| item.status.is_verified())
&& self.official_hash_errors.is_empty()
&& self.official_hash_verified_count == self.official_hash_pair_count
}
/// Returns the number of manifest entries that passed verification.
pub fn verified_count(&self) -> usize {
self.items
.iter()
.filter(|item| item.status.is_verified())
.count()
}
/// Returns the number of manifest entries that need attention.
pub fn failure_count(&self) -> usize {
self.items.len().saturating_sub(self.verified_count())
}
}
/// Existing local state for an official pull plan. /// Existing local state for an official pull plan.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OfficialLocalResourceState { pub struct OfficialLocalResourceState {
@@ -362,7 +412,7 @@ impl OfficialResourcePullService {
) -> Result<OfficialResourcePullReport, String> { ) -> Result<OfficialResourcePullReport, String> {
fs::create_dir_all(&self.output_root).map_err(|error| { fs::create_dir_all(&self.output_root).map_err(|error| {
format!( format!(
"Failed to create output root {}: {error}", "创建资源输出目录失败 {}{error}",
self.output_root.display() self.output_root.display()
) )
})?; })?;
@@ -378,7 +428,7 @@ impl OfficialResourcePullService {
let mut processed_urls = HashSet::<String>::new(); let mut processed_urls = HashSet::<String>::new();
for (offset, url) in urls.into_iter().enumerate() { for (offset, url) in urls.into_iter().enumerate() {
if should_cancel() { if should_cancel() {
return Err("official resource pull interrupted by shutdown request".to_string()); return Err("官方资源拉取已被停止请求中断".to_string());
} }
let index = offset + 1; let index = offset + 1;
@@ -389,16 +439,13 @@ impl OfficialResourcePullService {
)); ));
if !is_official_yostar_jp_url(&url) { if !is_official_yostar_jp_url(&url) {
return Err(format!("Refusing to download non-official URL: {url}")); return Err(format!("拒绝下载非官方 URL{url}"));
} }
let destination = self.destination_for_url(&url)?; let destination = self.destination_for_url(&url)?;
if let Some(parent) = destination.parent() { if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|error| { fs::create_dir_all(parent).map_err(|error| {
format!( format!("创建下载目标目录失败 {}{error}", parent.display())
"Failed to create destination directory {}: {error}",
parent.display()
)
})?; })?;
} }
@@ -443,7 +490,7 @@ impl OfficialResourcePullService {
} }
if should_cancel() { if should_cancel() {
return Err("official resource pull interrupted by shutdown request".to_string()); return Err("官方资源拉取已被停止请求中断".to_string());
} }
self.verify_all_official_hashes_are_complete(&official_hash_pairs, &verified_hash_urls)?; self.verify_all_official_hashes_are_complete(&official_hash_pairs, &verified_hash_urls)?;
@@ -473,6 +520,42 @@ impl OfficialResourcePullService {
Ok(OfficialLocalManifestAuditReport { items }) Ok(OfficialLocalManifestAuditReport { items })
} }
/// Verifies every entry currently present in the local download manifest.
///
/// This is intentionally network-free. It validates each recorded file
/// using the local manifest's size and BLAKE3 digest, then verifies every
/// local seed `.bytes`/`.hash` pair that is present using the official
/// decimal xxHash32 rule.
pub fn verify_local_download_manifest(
&self,
) -> Result<OfficialLocalVerificationReport, String> {
let manifest = self.read_download_manifest()?;
let urls = manifest.entries.keys().cloned().collect::<Vec<_>>();
let mut items = Vec::with_capacity(urls.len());
for url in urls {
let destination = self.destination_for_url(&url)?;
items.push(self.audit_one(&url, destination, &manifest)?);
}
let pairs = local_manifest_seed_hash_pairs(&manifest);
let mut official_hash_verified_count = 0;
let mut official_hash_errors = Vec::new();
for pair in &pairs {
match self.verify_official_seed_hash_pair_from_disk(pair) {
Ok(_) => official_hash_verified_count += 1,
Err(error) => official_hash_errors.push(error),
}
}
Ok(OfficialLocalVerificationReport {
items,
official_hash_pair_count: pairs.len(),
official_hash_verified_count,
official_hash_errors,
})
}
/// Returns whether the current output root already contains local state for /// Returns whether the current output root already contains local state for
/// this plan. This is intentionally cheaper than a full audit and is used /// this plan. This is intentionally cheaper than a full audit and is used
/// to choose between audit-then-repair and first-time full pull flows. /// to choose between audit-then-repair and first-time full pull flows.
@@ -508,7 +591,7 @@ impl OfficialResourcePullService {
/// `MediaCatalog.bytes`. /// `MediaCatalog.bytes`.
pub fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, String> { pub fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
if !is_official_yostar_jp_url(url) { if !is_official_yostar_jp_url(url) {
return Err(format!("Refusing to fetch non-official URL: {url}")); return Err(format!("拒绝拉取非官方 URL{url}"));
} }
let attempts = self.retry_attempts.max(1); let attempts = self.retry_attempts.max(1);
@@ -517,12 +600,12 @@ impl OfficialResourcePullService {
match self.fetch_bytes_once(url) { match self.fetch_bytes_once(url) {
Ok(bytes) => return Ok(bytes), Ok(bytes) => return Ok(bytes),
Err(error) => { Err(error) => {
last_error = Some(format!("attempt {attempt}/{attempts}: {error}")); last_error = Some(format!(" {attempt}/{attempts} 次尝试失败:{error}"));
} }
} }
} }
Err(last_error.unwrap_or_else(|| format!("curl failed for {url} without an attempt"))) Err(last_error.unwrap_or_else(|| format!("curl 未执行就已失败:{url}")))
} }
fn fetch_bytes_once(&self, url: &str) -> Result<Vec<u8>, String> { fn fetch_bytes_once(&self, url: &str) -> Result<Vec<u8>, String> {
@@ -536,14 +619,14 @@ impl OfficialResourcePullService {
.output() .output()
.map_err(|error| { .map_err(|error| {
format!( format!(
"Failed to launch curl command {}: {error}", "启动 curl 命令失败 {}{error}",
self.curl_command.display() self.curl_command.display()
) )
})?; })?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("curl failed for {url}: {}", stderr.trim())); return Err(format!("curl 拉取失败 {url}{}", stderr.trim()));
} }
Ok(output.stdout) Ok(output.stdout)
@@ -559,9 +642,7 @@ impl OfficialResourcePullService {
let _ = fs::remove_file(&partial); let _ = fs::remove_file(&partial);
self.download_one(url, &partial, false) self.download_one(url, &partial, false)
.map_err(|retry_error| { .map_err(|retry_error| {
format!( format!("续传失败 {url}{error};清理后重新下载也失败:{retry_error}")
"resume failed for {url}: {error}; clean retry also failed: {retry_error}"
)
})?; })?;
} }
} else { } else {
@@ -570,28 +651,20 @@ impl OfficialResourcePullService {
if file_len_if_exists(destination)?.is_some() { if file_len_if_exists(destination)?.is_some() {
fs::remove_file(destination).map_err(|error| { fs::remove_file(destination).map_err(|error| {
format!( format!("替换已有目标文件失败 {}{error}", destination.display())
"Failed to replace existing destination {}: {error}",
destination.display()
)
})?; })?;
} }
fs::rename(&partial, destination).map_err(|error| { fs::rename(&partial, destination).map_err(|error| {
format!( format!(
"Failed to move partial download {} -> {}: {error}", "移动临时下载文件失败 {} -> {}{error}",
partial.display(), partial.display(),
destination.display() destination.display()
) )
})?; })?;
let bytes = fs::metadata(destination) let bytes = fs::metadata(destination)
.map_err(|error| { .map_err(|error| format!("下载完成后目标文件缺失 {}{error}", destination.display()))?
format!(
"Downloaded file missing at {}: {error}",
destination.display()
)
})?
.len(); .len();
Ok(PullOneResult { Ok(PullOneResult {
@@ -612,17 +685,13 @@ impl OfficialResourcePullService {
match self.download_one_once(url, destination, resume) { match self.download_one_once(url, destination, resume) {
Ok(()) => return Ok(()), Ok(()) => return Ok(()),
Err(error) => { Err(error) => {
last_error = Some(format!("attempt {attempt}/{attempts}: {error}")); last_error = Some(format!(" {attempt}/{attempts} 次尝试失败:{error}"));
} }
} }
} }
Err(last_error.unwrap_or_else(|| { Err(last_error
format!( .unwrap_or_else(|| format!("curl 未执行就已失败:{url} -> {}", destination.display())))
"curl failed for {url} -> {} without an attempt",
destination.display()
)
}))
} }
fn download_one_once(&self, url: &str, destination: &Path, resume: bool) -> Result<(), String> { fn download_one_once(&self, url: &str, destination: &Path, resume: bool) -> Result<(), String> {
@@ -641,7 +710,7 @@ impl OfficialResourcePullService {
let output = command.output().map_err(|error| { let output = command.output().map_err(|error| {
format!( format!(
"Failed to launch curl command {}: {error}", "启动 curl 命令失败 {}{error}",
self.curl_command.display() self.curl_command.display()
) )
})?; })?;
@@ -649,7 +718,7 @@ impl OfficialResourcePullService {
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!( return Err(format!(
"curl failed for {url} -> {}: {}", "curl 下载失败 {url} -> {}{}",
destination.display(), destination.display(),
stderr.trim() stderr.trim()
)); ));
@@ -687,7 +756,7 @@ impl OfficialResourcePullService {
self.invalidate_download_manifest_hash_pair(manifest, pair) self.invalidate_download_manifest_hash_pair(manifest, pair)
{ {
return Err(format!( return Err(format!(
"{error}; additionally failed to invalidate local manifest entries for official hash pair: {cleanup_error}" "{error};同时清理官方 hash 对应的本地 manifest 条目失败:{cleanup_error}"
)); ));
} }
@@ -716,13 +785,13 @@ impl OfficialResourcePullService {
let hash_path = self.destination_for_url(&pair.hash_url)?; let hash_path = self.destination_for_url(&pair.hash_url)?;
let data = fs::read(&data_path).map_err(|error| { let data = fs::read(&data_path).map_err(|error| {
format!( format!(
"Failed to read official hash data file {}: {error}", "读取官方 hash 对应数据文件失败 {}{error}",
data_path.display() data_path.display()
) )
})?; })?;
let hash = fs::read(&hash_path).map_err(|error| { let hash = fs::read(&hash_path).map_err(|error| {
format!( format!(
"Failed to read official hash sidecar {}: {error}", "读取官方 hash sidecar 失败 {}{error}",
hash_path.display() hash_path.display()
) )
})?; })?;
@@ -748,7 +817,7 @@ impl OfficialResourcePullService {
for pair in pairs { for pair in pairs {
if !verified_hash_urls.contains(&pair.hash_url) { if !verified_hash_urls.contains(&pair.hash_url) {
return Err(format!( return Err(format!(
"official hash pair was not verified for {} using {}", "官方 hash 对未完成校验:数据={} hash={}",
pair.data_url, pair.hash_url pair.data_url, pair.hash_url
)); ));
} }
@@ -766,23 +835,18 @@ impl OfficialResourcePullService {
} }
Err(error) => { Err(error) => {
return Err(format!( return Err(format!(
"Failed to read download manifest {}: {error}", "读取下载 manifest 失败 {}{error}",
path.display() path.display()
)); ));
} }
}; };
let manifest: OfficialDownloadManifest = let manifest: OfficialDownloadManifest = serde_json::from_slice(&bytes)
serde_json::from_slice(&bytes).map_err(|error| { .map_err(|error| format!("解析下载 manifest 失败 {}{error}", path.display()))?;
format!(
"Failed to parse download manifest {}: {error}",
path.display()
)
})?;
if manifest.version != DOWNLOAD_MANIFEST_VERSION { if manifest.version != DOWNLOAD_MANIFEST_VERSION {
return Err(format!( return Err(format!(
"Unsupported download manifest version {} in {}", "不支持的下载 manifest 版本 {},文件 {}",
manifest.version, manifest.version,
path.display() path.display()
)); ));
@@ -795,29 +859,22 @@ impl OfficialResourcePullService {
let path = self.download_manifest_path(); let path = self.download_manifest_path();
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| { fs::create_dir_all(parent).map_err(|error| {
format!( format!("创建下载 manifest 目录失败 {}{error}", parent.display())
"Failed to create download manifest directory {}: {error}",
parent.display()
)
})?; })?;
} }
let temporary = path.with_extension("json.tmp"); let temporary = path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(manifest).map_err(|error| { let bytes = serde_json::to_vec_pretty(manifest)
format!( .map_err(|error| format!("序列化下载 manifest 失败 {}{error}", path.display()))?;
"Failed to serialize download manifest {}: {error}",
path.display()
)
})?;
fs::write(&temporary, bytes).map_err(|error| { fs::write(&temporary, bytes).map_err(|error| {
format!( format!(
"Failed to write temporary download manifest {}: {error}", "写入临时下载 manifest 失败 {}{error}",
temporary.display() temporary.display()
) )
})?; })?;
fs::rename(&temporary, &path).map_err(|error| { fs::rename(&temporary, &path).map_err(|error| {
format!( format!(
"Failed to move download manifest {} -> {}: {error}", "移动下载 manifest 失败 {} -> {}{error}",
temporary.display(), temporary.display(),
path.display() path.display()
) )
@@ -871,12 +928,7 @@ impl OfficialResourcePullService {
destination: &Path, destination: &Path,
) -> Result<(), String> { ) -> Result<(), String> {
let bytes = fs::metadata(destination) let bytes = fs::metadata(destination)
.map_err(|error| { .map_err(|error| format!("读取已下载文件信息失败 {}{error}", destination.display()))?
format!(
"Failed to inspect downloaded file {}: {error}",
destination.display()
)
})?
.len(); .len();
let digest = blake3_file_hex(destination)?; let digest = blake3_file_hex(destination)?;
let relative_destination = self.relative_destination(destination)?; let relative_destination = self.relative_destination(destination)?;
@@ -899,7 +951,7 @@ impl OfficialResourcePullService {
.strip_prefix(&self.output_root) .strip_prefix(&self.output_root)
.map_err(|error| { .map_err(|error| {
format!( format!(
"Destination {} is not under output root {}: {error}", "目标路径 {} 不在输出目录 {} 下:{error}",
destination.display(), destination.display(),
self.output_root.display() self.output_root.display()
) )
@@ -1013,15 +1065,15 @@ impl OfficialResourcePullService {
fn destination_for_url(&self, url: &str) -> Result<PathBuf, String> { fn destination_for_url(&self, url: &str) -> Result<PathBuf, String> {
if !is_official_yostar_jp_url(url) { if !is_official_yostar_jp_url(url) {
return Err(format!("URL is not an official JP host: {url}")); return Err(format!("URL 不是官方 JP host{url}"));
} }
let rest = url let rest = url
.strip_prefix("https://") .strip_prefix("https://")
.ok_or_else(|| format!("Official URL must use https: {url}"))?; .ok_or_else(|| format!("官方 URL 必须使用 https{url}"))?;
let (host, path) = rest let (host, path) = rest
.split_once('/') .split_once('/')
.ok_or_else(|| format!("Official URL has no path: {url}"))?; .ok_or_else(|| format!("官方 URL 缺少路径:{url}"))?;
let mut destination = PathBuf::from(sanitize_segment(host)); let mut destination = PathBuf::from(sanitize_segment(host));
for segment in path.split('/') { for segment in path.split('/') {
@@ -1029,7 +1081,7 @@ impl OfficialResourcePullService {
continue; continue;
} }
if segment == "." || segment == ".." { if segment == "." || segment == ".." {
return Err(format!("Official URL has unsafe path segment: {url}")); return Err(format!("官方 URL 包含不安全路径片段:{url}"));
} }
let segment = segment.split(['?', '#']).next().unwrap_or(segment); let segment = segment.split(['?', '#']).next().unwrap_or(segment);
@@ -1060,7 +1112,7 @@ pub fn verify_official_seed_catalog_hash(
if actual != expected { if actual != expected {
return Err(format!( return Err(format!(
"official hash mismatch for {data_url}: expected {expected} from {hash_url}, actual {actual}" "官方 hash 校验失败 {data_url}:期望 {expected}(来自 {hash_url}),实际 {actual}"
)); ));
} }
@@ -1097,6 +1149,28 @@ fn official_seed_hash_pairs(plan: &OfficialResourcePullPlan) -> Vec<OfficialSeed
.collect() .collect()
} }
fn local_manifest_seed_hash_pairs(
manifest: &OfficialDownloadManifest,
) -> Vec<OfficialSeedHashPair> {
manifest
.entries
.keys()
.filter_map(|data_url| {
let hash_url = data_url
.strip_suffix(".bytes")
.map(|prefix| format!("{prefix}.hash"))?;
if !manifest.entries.contains_key(&hash_url) {
return None;
}
Some(OfficialSeedHashPair {
data_url: data_url.clone(),
hash_url,
})
})
.collect()
}
fn official_hash_refresh_urls(pairs: &[OfficialSeedHashPair]) -> HashSet<String> { fn official_hash_refresh_urls(pairs: &[OfficialSeedHashPair]) -> HashSet<String> {
let mut urls = HashSet::new(); let mut urls = HashSet::new();
for pair in pairs { for pair in pairs {
@@ -1166,12 +1240,9 @@ struct PullOneResult {
fn file_len_if_exists(path: &Path) -> Result<Option<u64>, String> { fn file_len_if_exists(path: &Path) -> Result<Option<u64>, String> {
match fs::metadata(path) { match fs::metadata(path) {
Ok(metadata) if metadata.is_file() => Ok(Some(metadata.len())), Ok(metadata) if metadata.is_file() => Ok(Some(metadata.len())),
Ok(_) => Err(format!( Ok(_) => Err(format!("期望文件路径,但实际不是文件:{}", path.display())),
"Expected file path but found non-file: {}",
path.display()
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!("Failed to inspect {}: {error}", path.display())), Err(error) => Err(format!("读取路径信息失败 {}{error}", path.display())),
} }
} }
@@ -1183,14 +1254,14 @@ fn partial_path_for(destination: &Path) -> PathBuf {
fn blake3_file_hex(path: &Path) -> Result<String, String> { fn blake3_file_hex(path: &Path) -> Result<String, String> {
let mut file = let mut file =
File::open(path).map_err(|error| format!("Failed to open {}: {error}", path.display()))?; File::open(path).map_err(|error| format!("打开文件失败 {}{error}", path.display()))?;
let mut hasher = blake3::Hasher::new(); let mut hasher = blake3::Hasher::new();
let mut buffer = [0u8; 64 * 1024]; let mut buffer = [0u8; 64 * 1024];
loop { loop {
let read = file let read = file
.read(&mut buffer) .read(&mut buffer)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?; .map_err(|error| format!("读取文件失败 {}{error}", path.display()))?;
if read == 0 { if read == 0 {
break; break;
} }
@@ -1202,11 +1273,10 @@ fn blake3_file_hex(path: &Path) -> Result<String, String> {
fn parse_official_xxhash32_decimal(hash_url: &str, bytes: &[u8]) -> Result<u32, String> { fn parse_official_xxhash32_decimal(hash_url: &str, bytes: &[u8]) -> Result<u32, String> {
let text = std::str::from_utf8(bytes) let text = std::str::from_utf8(bytes)
.map_err(|error| format!("Official hash sidecar is not UTF-8 at {hash_url}: {error}"))? .map_err(|error| format!("官方 hash sidecar 不是 UTF-8{hash_url}{error}"))?
.trim(); .trim();
text.parse::<u32>().map_err(|error| { text.parse::<u32>()
format!("Official hash sidecar is not decimal xxHash32 at {hash_url}: {error}") .map_err(|error| format!("官方 hash sidecar 不是十进制 xxHash32{hash_url}{error}"))
})
} }
fn xxhash32(bytes: &[u8]) -> u32 { fn xxhash32(bytes: &[u8]) -> u32 {
@@ -1649,6 +1719,37 @@ fi
assert!(service.audit_local_manifest(&plan).unwrap().is_clean()); assert!(service.audit_local_manifest(&plan).unwrap().is_clean());
} }
#[test]
fn full_local_verification_checks_manifest_and_official_hash_pairs() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path);
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
let plan = build_official_pull_plan(discovery_plan(), inventory());
service.pull(&plan).unwrap();
let verification = service.verify_local_download_manifest().unwrap();
assert!(verification.is_clean());
assert_eq!(verification.items.len(), 7);
assert_eq!(verification.verified_count(), 7);
assert_eq!(verification.official_hash_pair_count, 1);
assert_eq!(verification.official_hash_verified_count, 1);
let data_path = service
.destination_for_url(
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes",
)
.unwrap();
fs::write(data_path, b"corrupted").unwrap();
let corrupted = service.verify_local_download_manifest().unwrap();
assert!(!corrupted.is_clean());
assert_eq!(corrupted.failure_count(), 1);
assert_eq!(corrupted.official_hash_errors.len(), 1);
}
#[test] #[test]
fn refreshes_official_hash_pairs_and_skips_manifest_verified_content() { fn refreshes_official_hash_pairs_and_skips_manifest_verified_content() {
let out_dir = TempDir::new().unwrap(); let out_dir = TempDir::new().unwrap();
@@ -1833,7 +1934,7 @@ fi
}; };
let error = service.pull(&plan).unwrap_err(); let error = service.pull(&plan).unwrap_err();
assert!(error.contains("official hash mismatch")); assert!(error.contains("官方 hash 校验失败"));
let manifest = service.read_download_manifest().unwrap(); let manifest = service.read_download_manifest().unwrap();
assert!(manifest.entries.is_empty()); assert!(manifest.entries.is_empty());
+22 -26
View File
@@ -83,10 +83,9 @@ impl OfficialGameMainConfigBootstrapService {
.source .source
.clone() .clone()
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.ok_or_else(|| "Official launcher remote manifest has no source".to_string())?; .ok_or_else(|| "官方启动器远端 manifest 缺少 source".to_string())?;
let cdn_config = self.launcher.fetch_cdn_config()?; let cdn_config = self.launcher.fetch_cdn_config()?;
let temp_dir = TempDir::new() let temp_dir = TempDir::new().map_err(|error| format!("创建临时目录失败:{error}"))?;
.map_err(|error| format!("Failed to create temporary directory: {error}"))?;
let (game_zip_url, resources_assets) = self.fetch_resources_assets( let (game_zip_url, resources_assets) = self.fetch_resources_assets(
&game_config, &game_config,
&manifest, &manifest,
@@ -121,12 +120,12 @@ impl OfficialGameMainConfigBootstrapService {
match self.download_file_once(url, destination) { match self.download_file_once(url, destination) {
Ok(()) => return Ok(()), Ok(()) => return Ok(()),
Err(error) => { Err(error) => {
last_error = Some(format!("attempt {attempt}/{attempts}: {error}")); last_error = Some(format!(" {attempt}/{attempts} 次尝试失败:{error}"));
} }
} }
} }
Err(last_error.unwrap_or_else(|| format!("curl failed for {url} without an attempt"))) Err(last_error.unwrap_or_else(|| format!("curl 未执行就已失败:{url}")))
} }
fn download_file_once(&self, url: &str, destination: &Path) -> Result<(), String> { fn download_file_once(&self, url: &str, destination: &Path) -> Result<(), String> {
@@ -142,14 +141,14 @@ impl OfficialGameMainConfigBootstrapService {
.output() .output()
.map_err(|error| { .map_err(|error| {
format!( format!(
"Failed to launch curl command {}: {error}", "启动 curl 命令失败 {}{error}",
self.curl_command.display() self.curl_command.display()
) )
})?; })?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("curl failed for {url}: {}", stderr.trim())); return Err(format!("curl 拉取失败 {url}{}", stderr.trim()));
} }
Ok(()) Ok(())
@@ -168,7 +167,7 @@ impl OfficialGameMainConfigBootstrapService {
let backup_url = launcher_package_url(backup_cdn_root, relative_path)?; let backup_url = launcher_package_url(backup_cdn_root, relative_path)?;
self.download_file(&backup_url, destination).map_err(|backup_error| { self.download_file(&backup_url, destination).map_err(|backup_error| {
format!( format!(
"failed to download official game ZIP from primary ({primary_error}) and backup ({backup_error})" "下载官方游戏资源失败:主地址失败({primary_error}),备用地址也失败({backup_error}"
) )
}) })
} }
@@ -195,11 +194,10 @@ impl OfficialGameMainConfigBootstrapService {
)?; )?;
let extract_root = temp_root.join("extract"); let extract_root = temp_root.join("extract");
fs::create_dir_all(&extract_root) fs::create_dir_all(&extract_root)
.map_err(|error| format!("Failed to create extract root: {error}"))?; .map_err(|error| format!("创建解压目录失败:{error}"))?;
self.extract_archive(&archive_path, &extract_root)?; self.extract_archive(&archive_path, &extract_root)?;
let resources_assets = find_resources_assets(&extract_root).ok_or_else(|| { let resources_assets = find_resources_assets(&extract_root)
"resources.assets not found in official launcher package".to_string() .ok_or_else(|| "官方启动器包内没有找到 resources.assets".to_string())?;
})?;
Ok((game_zip_url, resources_assets)) Ok((game_zip_url, resources_assets))
} }
GameMainConfigSource::ManifestFile { source_dir, file } => { GameMainConfigSource::ManifestFile { source_dir, file } => {
@@ -228,7 +226,7 @@ impl OfficialGameMainConfigBootstrapService {
.output() .output()
.map_err(|error| { .map_err(|error| {
format!( format!(
"Failed to launch unzip command {}: {error}", "启动 unzip 命令失败 {}{error}",
self.unzip_command.display() self.unzip_command.display()
) )
})?; })?;
@@ -236,7 +234,7 @@ impl OfficialGameMainConfigBootstrapService {
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!( return Err(format!(
"unzip failed for {}: {}", "解压失败 {}{}",
archive_path.display(), archive_path.display(),
stderr.trim() stderr.trim()
)); ));
@@ -271,7 +269,7 @@ fn select_game_main_config_source<'a>(
}); });
} }
return Err(format!( return Err(format!(
"Official launcher manifest directory source has no BlueArchive_Data/resources.assets entry: {manifest_source}" "官方启动器 manifest 目录 source 缺少 BlueArchive_Data/resources.assets 条目:{manifest_source}"
)); ));
} }
@@ -282,7 +280,7 @@ fn select_game_main_config_source<'a>(
} }
Err(format!( Err(format!(
"Official launcher metadata has no usable game archive path: source={manifest_source}, game_latest_file_path={}", "官方启动器元数据没有可用的游戏包路径:source={manifest_source}, game_latest_file_path={}",
game_config.game_latest_file_path game_config.game_latest_file_path
)) ))
} }
@@ -318,9 +316,9 @@ fn launcher_manifest_file_relative_path(
file_path: &str, file_path: &str,
) -> Result<String, String> { ) -> Result<String, String> {
let source = normalize_manifest_source_dir(source_dir) let source = normalize_manifest_source_dir(source_dir)
.ok_or_else(|| format!("Invalid official launcher manifest source: {source_dir}"))?; .ok_or_else(|| format!("官方启动器 manifest source 无效:{source_dir}"))?;
let file = normalize_manifest_file_path(file_path) let file = normalize_manifest_file_path(file_path)
.ok_or_else(|| format!("Invalid official launcher manifest file path: {file_path}"))?; .ok_or_else(|| format!("官方启动器 manifest 文件路径无效:{file_path}"))?;
Ok(format!("{source}/{file}")) Ok(format!("{source}/{file}"))
} }
@@ -358,18 +356,16 @@ fn verify_manifest_file_size(
path: &Path, path: &Path,
file: &YostarJpLauncherManifestFile, file: &YostarJpLauncherManifestFile,
) -> Result<(), String> { ) -> Result<(), String> {
let expected_size = file.size.parse::<u64>().map_err(|error| { let expected_size = file
format!( .size
"Official launcher manifest has invalid size for {}: {error}", .parse::<u64>()
file.path .map_err(|error| format!("官方启动器 manifest 中 {} 的 size 无效:{error}", file.path))?;
)
})?;
let actual_size = fs::metadata(path) let actual_size = fs::metadata(path)
.map_err(|error| format!("Failed to stat downloaded {}: {error}", path.display()))? .map_err(|error| format!("读取已下载文件信息失败 {}{error}", path.display()))?
.len(); .len();
if actual_size != expected_size { if actual_size != expected_size {
return Err(format!( return Err(format!(
"Downloaded official launcher file size mismatch for {}: expected {}, got {}", "已下载官方启动器文件大小不匹配 {}:期望 {},实际 {}",
file.path, expected_size, actual_size file.path, expected_size, actual_size
)); ));
} }
+27 -33
View File
@@ -109,13 +109,11 @@ impl OfficialLauncherBootstrapService {
/// Fetches latest official PC game config. /// Fetches latest official PC game config.
pub fn fetch_game_config(&self) -> Result<YostarJpLauncherGameConfig, String> { pub fn fetch_game_config(&self) -> Result<YostarJpLauncherGameConfig, String> {
let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?; let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?;
let config: YostarJpLauncherGameConfig = let config: YostarJpLauncherGameConfig = serde_json::from_value(envelope.data)
serde_json::from_value(envelope.data).map_err(|error| { .map_err(|error| format!("解析官方启动器 game config 数据失败:{error}"))?;
format!("Failed to parse official launcher game config data: {error}")
})?;
if config.game_latest_version.is_empty() || config.game_latest_file_path.is_empty() { if config.game_latest_version.is_empty() || config.game_latest_file_path.is_empty() {
return Err("Official launcher game config is missing latest version or basis".into()); return Err("官方启动器 game config 缺少最新版本或基准路径".into());
} }
Ok(config) Ok(config)
@@ -125,7 +123,7 @@ impl OfficialLauncherBootstrapService {
pub fn fetch_cdn_config(&self) -> Result<YostarJpLauncherCdnConfig, String> { pub fn fetch_cdn_config(&self) -> Result<YostarJpLauncherCdnConfig, String> {
let envelope = self.fetch_launcher_envelope("/api/launcher/advanced/game/download/cdn")?; let envelope = self.fetch_launcher_envelope("/api/launcher/advanced/game/download/cdn")?;
serde_json::from_value(envelope.data) serde_json::from_value(envelope.data)
.map_err(|error| format!("Failed to parse official launcher CDN config data: {error}")) .map_err(|error| format!("解析官方启动器 CDN config 数据失败:{error}"))
} }
/// Fetches the official remote manifest URL for a PC client version and /// Fetches the official remote manifest URL for a PC client version and
@@ -136,7 +134,7 @@ impl OfficialLauncherBootstrapService {
file_path: &str, file_path: &str,
) -> Result<YostarJpLauncherManifestUrl, String> { ) -> Result<YostarJpLauncherManifestUrl, String> {
if version.is_empty() || file_path.is_empty() { if version.is_empty() || file_path.is_empty() {
return Err("Launcher manifest version and file path must not be empty".into()); return Err("启动器 manifest 版本和文件路径不能为空".into());
} }
let path = format!( let path = format!(
@@ -146,13 +144,11 @@ impl OfficialLauncherBootstrapService {
); );
let envelope = self.fetch_launcher_envelope(&path)?; let envelope = self.fetch_launcher_envelope(&path)?;
let manifest_url: YostarJpLauncherManifestUrl = serde_json::from_value(envelope.data) let manifest_url: YostarJpLauncherManifestUrl = serde_json::from_value(envelope.data)
.map_err(|error| { .map_err(|error| format!("解析官方启动器 manifest URL 数据失败:{error}"))?;
format!("Failed to parse official launcher manifest URL data: {error}")
})?;
if !is_official_launcher_package_url(&manifest_url.url) { if !is_official_launcher_package_url(&manifest_url.url) {
return Err(format!( return Err(format!(
"Official launcher API returned non-official manifest URL: {}", "官方启动器 API 返回了非官方 manifest URL{}",
manifest_url.url manifest_url.url
)); ));
} }
@@ -166,9 +162,7 @@ impl OfficialLauncherBootstrapService {
manifest_url: &str, manifest_url: &str,
) -> Result<YostarJpLauncherRemoteManifest, String> { ) -> Result<YostarJpLauncherRemoteManifest, String> {
if !is_official_launcher_package_url(manifest_url) { if !is_official_launcher_package_url(manifest_url) {
return Err(format!( return Err(format!("拒绝拉取非官方启动器 manifest URL{manifest_url}"));
"Refusing to fetch non-official launcher manifest URL: {manifest_url}"
));
} }
let output = self.run_curl_with_retry( let output = self.run_curl_with_retry(
@@ -186,16 +180,16 @@ impl OfficialLauncherBootstrapService {
|output| { |output| {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
format!( format!(
"curl failed for launcher manifest {manifest_url}: {}", "curl 拉取启动器 manifest 失败 {manifest_url}{}",
stderr.trim() stderr.trim()
) )
}, },
)?; )?;
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout) let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("Failed to parse official launcher manifest: {error}"))?; .map_err(|error| format!("解析官方启动器 manifest 失败:{error}"))?;
if manifest.files.is_empty() { if manifest.files.is_empty() {
return Err("Official launcher remote manifest has no files".into()); return Err("官方启动器远端 manifest 没有文件条目".into());
} }
Ok(manifest) Ok(manifest)
@@ -246,22 +240,22 @@ impl OfficialLauncherBootstrapService {
}, },
|output| { |output| {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
format!("curl failed for {url}: {}", stderr.trim()) format!("curl 拉取失败 {url}{}", stderr.trim())
}, },
)?; )?;
let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout) let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("Failed to parse official launcher API response: {error}"))?; .map_err(|error| format!("解析官方启动器 API 响应失败:{error}"))?;
if envelope.code != 200 { if envelope.code != 200 {
return Err(format!( return Err(format!(
"Official launcher API returned code {}: {}", "官方启动器 API 返回错误码 {}{}",
envelope.code, envelope.code,
envelope envelope
.message .message
.as_deref() .as_deref()
.or(envelope.msg.as_deref()) .or(envelope.msg.as_deref())
.unwrap_or("<no message>") .unwrap_or("<无消息>")
)); ));
} }
@@ -280,20 +274,20 @@ impl OfficialLauncherBootstrapService {
Ok(output) if output.status.success() => return Ok(output), Ok(output) if output.status.success() => return Ok(output),
Ok(output) => { Ok(output) => {
last_error = Some(format!( last_error = Some(format!(
"attempt {attempt}/{attempts}: {}", " {attempt}/{attempts} 次尝试失败:{}",
failure_message(&output) failure_message(&output)
)); ));
} }
Err(error) => { Err(error) => {
last_error = Some(format!( last_error = Some(format!(
"attempt {attempt}/{attempts}: Failed to launch curl command {}: {error}", " {attempt}/{attempts} 次尝试失败:启动 curl 命令失败 {}{error}",
self.curl_command self.curl_command
)); ));
} }
} }
} }
Err(last_error.unwrap_or_else(|| format!("curl command {} did not run", self.curl_command))) Err(last_error.unwrap_or_else(|| format!("curl 命令未执行:{}", self.curl_command)))
} }
} }
@@ -315,7 +309,7 @@ pub struct LauncherEnvelope {
/// Builds an official launcher API URL from a path. /// Builds an official launcher API URL from a path.
pub fn launcher_api_url(path: &str) -> Result<String, String> { pub fn launcher_api_url(path: &str) -> Result<String, String> {
if !path.starts_with('/') || path.contains("..") || path.contains('\\') { if !path.starts_with('/') || path.contains("..") || path.contains('\\') {
return Err(format!("Invalid official launcher API path: {path}")); return Err(format!("官方启动器 API 路径无效:{path}"));
} }
Ok(format!("{YOSTAR_JP_LAUNCHER_API_ROOT}{path}")) Ok(format!("{YOSTAR_JP_LAUNCHER_API_ROOT}{path}"))
@@ -337,7 +331,7 @@ pub fn is_official_launcher_package_url(url: &str) -> bool {
/// relative package path. /// relative package path.
pub fn launcher_package_url(cdn_root: &str, file_path: &str) -> Result<String, String> { pub fn launcher_package_url(cdn_root: &str, file_path: &str) -> Result<String, String> {
if !is_official_launcher_package_url(cdn_root) { if !is_official_launcher_package_url(cdn_root) {
return Err(format!("Launcher CDN root is not official: {cdn_root}")); return Err(format!("启动器 CDN 根地址不是官方地址:{cdn_root}"));
} }
validate_launcher_package_path(file_path)?; validate_launcher_package_path(file_path)?;
@@ -368,15 +362,15 @@ fn normalize_relative_path(path: &str) -> String {
fn validate_launcher_package_path(path: &str) -> Result<(), String> { fn validate_launcher_package_path(path: &str) -> Result<(), String> {
if path.is_empty() || path.starts_with('/') || path.starts_with('\\') { if path.is_empty() || path.starts_with('/') || path.starts_with('\\') {
return Err(format!("Invalid launcher package path: {path}")); return Err(format!("启动器包路径无效:{path}"));
} }
for segment in path.split('/') { for segment in path.split('/') {
if segment.is_empty() || segment == "." || segment == ".." { if segment.is_empty() || segment == "." || segment == ".." {
return Err(format!("Invalid launcher package path: {path}")); return Err(format!("启动器包路径无效:{path}"));
} }
if segment.contains('\\') || segment.contains('?') || segment.contains('#') { if segment.contains('\\') || segment.contains('?') || segment.contains('#') {
return Err(format!("Invalid launcher package path: {path}")); return Err(format!("启动器包路径无效:{path}"));
} }
} }
@@ -394,14 +388,14 @@ pub fn launcher_authorization_header(
timestamp: Option<u64>, timestamp: Option<u64>,
) -> Result<String, String> { ) -> Result<String, String> {
if launcher_version.is_empty() { if launcher_version.is_empty() {
return Err("Launcher version must not be empty".into()); return Err("启动器版本不能为空".into());
} }
let timestamp = match timestamp { let timestamp = match timestamp {
Some(timestamp) => timestamp, Some(timestamp) => timestamp,
None => SystemTime::now() None => SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map_err(|error| format!("System time is before Unix epoch: {error}"))? .map_err(|error| format!("系统时间早于 Unix epoch{error}"))?
.as_secs(), .as_secs(),
}; };
@@ -411,7 +405,7 @@ pub fn launcher_authorization_header(
"version": launcher_version, "version": launcher_version,
}); });
let head_json = serde_json::to_string(&head) let head_json = serde_json::to_string(&head)
.map_err(|error| format!("Failed to serialize launcher auth head: {error}"))?; .map_err(|error| format!("序列化启动器认证 head 失败:{error}"))?;
let mut hasher = Md5::new(); let mut hasher = Md5::new();
hasher.update(head_json.as_bytes()); hasher.update(head_json.as_bytes());
hasher.update(body.as_bytes()); hasher.update(body.as_bytes());
@@ -422,7 +416,7 @@ pub fn launcher_authorization_header(
"head": head, "head": head,
"sign": sign, "sign": sign,
})) }))
.map_err(|error| format!("Failed to serialize launcher auth header: {error}")) .map_err(|error| format!("序列化启动器认证 header 失败:{error}"))
} }
#[cfg(test)] #[cfg(test)]
+72 -82
View File
@@ -87,7 +87,7 @@ impl Default for OfficialUpdateConfig {
launcher_version: "1.7.2".to_string(), launcher_version: "1.7.2".to_string(),
auto_discover: false, auto_discover: false,
platforms: None, platforms: None,
output_root: PathBuf::from("official_update_output"), output_root: PathBuf::from("./bat-resources"),
snapshot_path: None, snapshot_path: None,
curl_command: PathBuf::from("curl"), curl_command: PathBuf::from("curl"),
unzip_command: PathBuf::from("unzip"), unzip_command: PathBuf::from("unzip"),
@@ -442,7 +442,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"start", "start",
format!( format!(
"starting official resource sync: output={} dry_run={} auto_discover={}", "开始官方资源同步:资源目录={} 试运行={} 自动发现={}",
config.output_root.display(), config.output_root.display(),
config.dry_run, config.dry_run,
config.auto_discover config.auto_discover
@@ -451,18 +451,15 @@ impl OfficialUpdateService {
check_shutdown_requested(&mut should_cancel)?; check_shutdown_requested(&mut should_cancel)?;
let _lock = if config.dry_run { let _lock = if config.dry_run {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new("lock", "试运行:跳过状态锁"));
"lock",
"dry-run: skipping state lock",
));
None None
} else { } else {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"lock", "lock",
format!("acquiring state lock {}", config.lock_path().display()), format!("获取资源目录状态锁 {}", config.lock_path().display()),
)); ));
let lock = OfficialUpdateLock::acquire(config)?; let lock = OfficialUpdateLock::acquire(config)?;
progress(OfficialUpdateProgress::new("lock", "state lock acquired")); progress(OfficialUpdateProgress::new("lock", "资源目录状态锁已获取"));
Some(lock) Some(lock)
}; };
check_shutdown_requested(&mut should_cancel)?; check_shutdown_requested(&mut should_cancel)?;
@@ -483,7 +480,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"bootstrap", "bootstrap",
format!( format!(
"auto-discover enabled; launcher_version={} cache={}", "启用自动发现;启动器版本={} 缓存={}",
config.launcher_version, config.launcher_version,
bootstrap_cache_path.display() bootstrap_cache_path.display()
), ),
@@ -500,7 +497,7 @@ impl OfficialUpdateService {
} else { } else {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"bootstrap", "bootstrap",
"auto-discover disabled; using explicit metadata inputs", "未启用自动发现;使用显式元数据输入",
)); ));
None None
}; };
@@ -515,7 +512,7 @@ impl OfficialUpdateService {
.map(|bootstrap| bootstrap.launcher_metadata.game_latest_version.clone()) .map(|bootstrap| bootstrap.launcher_metadata.game_latest_version.clone())
}) })
.ok_or_else(|| { .ok_or_else(|| {
anyhow::anyhow!("missing app version; pass it explicitly or enable auto-discover") anyhow::anyhow!("缺少应用版本;请显式传入 --app-version 或启用 --auto-discover")
})?; })?;
let connection_group = config let connection_group = config
.connection_group .connection_group
@@ -526,14 +523,12 @@ impl OfficialUpdateService {
}) })
}) })
.ok_or_else(|| { .ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!("缺少连接组;请显式传入 --connection-group 或启用 --auto-discover")
"missing connection group; pass it explicitly or enable auto-discover"
)
})?; })?;
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"metadata", "metadata",
format!( format!(
"using app_version={} connection_group={} platforms={}", "使用应用版本={} 连接组={} 平台={}",
app_version, app_version,
connection_group, connection_group,
platforms_label(platforms) platforms_label(platforms)
@@ -543,35 +538,30 @@ impl OfficialUpdateService {
let server_info_bytes = if let Some(source) = config.server_info_source.as_ref() { let server_info_bytes = if let Some(source) = config.server_info_source.as_ref() {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"server-info", "server-info",
format!( format!("{} 读取服务器信息", server_info_source_label(source)),
"loading server-info from {}",
server_info_source_label(source)
),
)); ));
load_server_info(source, &fetcher)? load_server_info(source, &fetcher)?
} else { } else {
let bootstrap = bootstrap.as_ref().ok_or_else(|| { let bootstrap = bootstrap.as_ref().ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"missing server-info source; pass one explicitly or enable auto-discover" "缺少服务器信息来源;请显式传入 --server-info-* 或启用 --auto-discover"
) )
})?; })?;
let url = bootstrap let url = bootstrap
.game_main_config .game_main_config
.server_info_data_url .server_info_data_url
.as_deref() .as_deref()
.ok_or_else(|| { .ok_or_else(|| anyhow::anyhow!("官方 GameMainConfig 中没有 ServerInfoDataUrl"))?;
anyhow::anyhow!("official GameMainConfig has no ServerInfoDataUrl")
})?;
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"server-info", "server-info",
format!("fetching discovered server-info {url}"), format!("拉取自动发现的服务器信息 {url}"),
)); ));
fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)? fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)?
}; };
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"server-info", "server-info",
format!("parsing server-info bytes={}", server_info_bytes.len()), format!("解析服务器信息,字节数={}", server_info_bytes.len()),
)); ));
check_shutdown_requested(&mut should_cancel)?; check_shutdown_requested(&mut should_cancel)?;
let server_info = YostarJpServerInfo::from_slice(&server_info_bytes)?; let server_info = YostarJpServerInfo::from_slice(&server_info_bytes)?;
@@ -581,7 +571,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"discovery", "discovery",
format!( format!(
"resolved addressables_root={} endpoints={}", "解析到 Addressables={} endpoint={}",
current_snapshot.addressables_root, current_snapshot.addressables_root,
current_snapshot.endpoints.len() current_snapshot.endpoints.len()
), ),
@@ -589,7 +579,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"markers", "markers",
format!( format!(
"checking {} remote marker endpoints", "检查 {} 个远端标记 endpoint",
marker_endpoint_count(&current_snapshot) marker_endpoint_count(&current_snapshot)
), ),
)); ));
@@ -607,7 +597,7 @@ impl OfficialUpdateService {
check_shutdown_requested(&mut should_cancel)?; check_shutdown_requested(&mut should_cancel)?;
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"snapshot", "snapshot",
format!("reading previous snapshot {}", snapshot_path.display()), format!("读取上次快照 {}", snapshot_path.display()),
)); ));
let previous_snapshot = read_snapshot(&snapshot_path)?; let previous_snapshot = read_snapshot(&snapshot_path)?;
let previous_base_snapshot = previous_snapshot let previous_base_snapshot = previous_snapshot
@@ -623,13 +613,13 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"decision", "decision",
format!( format!(
"remote decision={:?} force={} remote_should_download={}", "远端决策={:?} 强制刷新={} 远端需要下载={}",
sync_plan.decision, config.force, remote_should_download sync_plan.decision, config.force, remote_should_download
), ),
)); ));
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"plan", "plan",
"building official pull plan from latest seed catalogs", "根据最新种子目录构建官方拉取计划",
)); ));
let pull_plan = build_pull_plan( let pull_plan = build_pull_plan(
&server_info, &server_info,
@@ -643,7 +633,7 @@ impl OfficialUpdateService {
let url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len(); let url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"plan", "plan",
format!("pull plan contains {url_count} official URLs"), format!("拉取计划包含 {url_count} 个官方 URL"),
)); ));
let local_state = fetcher let local_state = fetcher
.local_resource_state(&pull_plan) .local_resource_state(&pull_plan)
@@ -652,7 +642,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"local-state", "local-state",
format!( format!(
"manifest_entries={} existing_files={} has_local_resources={}", "manifest 条目={} 已存在文件={} 是否已有本地资源={}",
local_state.manifest_entry_count, local_state.manifest_entry_count,
local_state.existing_file_count, local_state.existing_file_count,
has_local_resources has_local_resources
@@ -663,7 +653,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"audit", "audit",
format!( format!(
"auditing local download manifest {}", "审计本地下载 manifest {}",
fetcher.download_manifest_path().display() fetcher.download_manifest_path().display()
), ),
)); ));
@@ -675,13 +665,13 @@ impl OfficialUpdateService {
} else if config.audit_local { } else if config.audit_local {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"audit", "audit",
"no local resources found; skipping local audit before first full pull", "未发现本地资源;首次全量拉取前跳过本地审计",
)); ));
None None
} else { } else {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"audit", "audit",
"local manifest audit disabled", "本地 manifest 审计已关闭",
)); ));
None None
}; };
@@ -703,14 +693,14 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"audit", "audit",
format!( format!(
"local manifest verified={} repair_needed={}", "本地 manifest 已校验={} 需要修复={}",
local_manifest_verified_count, local_manifest_repair_needed_count local_manifest_verified_count, local_manifest_repair_needed_count
), ),
)); ));
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"decision", "decision",
format!( format!(
"final should_download={} repair_needed={} initial_pull_needed={}", "最终决策 需要下载={} 需要修复={} 首次拉取={}",
should_download, repair_needed, initial_pull_needed should_download, repair_needed, initial_pull_needed
), ),
)); ));
@@ -760,7 +750,7 @@ impl OfficialUpdateService {
if !should_download { if !should_download {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"finish", "finish",
"up to date; no download needed", "资源已是最新;无需下载",
)); ));
return Ok(report); return Ok(report);
} }
@@ -773,20 +763,20 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"dry-run", "dry-run",
format!( format!(
"dry-run plan generated {} URLs", "试运行已生成 {} URL",
report.download_url_count.unwrap_or(0) report.download_url_count.unwrap_or(0)
), ),
)); ));
} else { } else {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"dry-run", "dry-run",
"dry-run detected pending download; full URL plan disabled", "试运行检测到需要下载;未启用完整 URL 计划输出",
)); ));
} }
report.update_status = OfficialUpdateStatus::WouldDownload; report.update_status = OfficialUpdateStatus::WouldDownload;
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"finish", "finish",
"dry-run finished without writing state", "试运行完成,未写入状态",
)); ));
return Ok(report); return Ok(report);
} }
@@ -795,7 +785,7 @@ impl OfficialUpdateService {
let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len(); let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"download", "download",
format!("downloading or reusing {download_url_count} official URLs"), format!("下载或复用 {download_url_count} 个官方 URL"),
)); ));
let pull_report = fetcher let pull_report = fetcher
.pull_with_progress_and_cancellation( .pull_with_progress_and_cancellation(
@@ -809,7 +799,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"download", "download",
format!( format!(
"download phase finished: downloaded={} resumed={} skipped={} transferred_bytes={}", "下载阶段完成:已下载={} 已续传={} 已复用={} 本轮传输字节={}",
pull_report.downloaded_count(), pull_report.downloaded_count(),
pull_report.resumed_count(), pull_report.resumed_count(),
pull_report.skipped_count(), pull_report.skipped_count(),
@@ -818,7 +808,7 @@ impl OfficialUpdateService {
)); ));
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"audit", "audit",
"running final local manifest audit", "执行最终本地 manifest 审计",
)); ));
check_shutdown_requested(&mut should_cancel)?; check_shutdown_requested(&mut should_cancel)?;
let final_audit = fetcher let final_audit = fetcher
@@ -826,7 +816,7 @@ impl OfficialUpdateService {
.map_err(anyhow::Error::msg)?; .map_err(anyhow::Error::msg)?;
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"snapshot", "snapshot",
format!("writing snapshot {}", snapshot_path.display()), format!("写入快照 {}", snapshot_path.display()),
)); ));
write_snapshot(&snapshot_path, &current_update_snapshot)?; write_snapshot(&snapshot_path, &current_update_snapshot)?;
@@ -845,7 +835,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"finish", "finish",
format!( format!(
"sync finished: resources={} official_hashes_verified={}", "同步完成:资源数={} 官方 hash 已校验={}",
report.resource_count.unwrap_or(0), report.resource_count.unwrap_or(0),
report.official_seed_hash_verified_count report.official_seed_hash_verified_count
), ),
@@ -870,7 +860,7 @@ fn build_pull_plan(
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"plan", "plan",
format!( format!(
"discovery plan resolved: connection_group={} app_version={} endpoints={}", "发现计划已解析:连接组={} 应用版本={} endpoint={}",
discovery.connection_group_name, discovery.connection_group_name,
discovery.app_version, discovery.app_version,
discovery.endpoints.len() discovery.endpoints.len()
@@ -881,7 +871,7 @@ fn build_pull_plan(
check_shutdown_requested(should_cancel)?; check_shutdown_requested(should_cancel)?;
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"inventory", "inventory",
"parsing seed catalogs into download inventory", "正在把种子目录解析为下载清单",
)); ));
let inventory = build_inventory_from_seed_catalogs(&seed_catalogs, platforms)?; let inventory = build_inventory_from_seed_catalogs(&seed_catalogs, platforms)?;
Ok(build_official_pull_plan_for_platform_inventory( Ok(build_official_pull_plan_for_platform_inventory(
@@ -921,7 +911,7 @@ fn collect_endpoint_markers(
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"marker", "marker",
format!( format!(
"fetching {} marker{} {}", "拉取 {} 标记{} {}",
endpoint_kind_label(endpoint.kind), endpoint_kind_label(endpoint.kind),
platform_suffix(endpoint.platform), platform_suffix(endpoint.platform),
endpoint.url endpoint.url
@@ -946,9 +936,7 @@ fn collect_endpoint_markers(
fn check_shutdown_requested(should_cancel: &mut dyn FnMut() -> bool) -> anyhow::Result<()> { fn check_shutdown_requested(should_cancel: &mut dyn FnMut() -> bool) -> anyhow::Result<()> {
if should_cancel() { if should_cancel() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!("官方资源更新已被停止请求中断"));
"official update interrupted by shutdown request"
));
} }
Ok(()) Ok(())
@@ -978,11 +966,11 @@ fn marker_endpoint_count(snapshot: &YostarJpSyncSnapshot) -> usize {
fn server_info_source_label(source: &OfficialServerInfoSource) -> String { fn server_info_source_label(source: &OfficialServerInfoSource) -> String {
match source { match source {
OfficialServerInfoSource::LocalPath(path) => format!("local path {}", path.display()), OfficialServerInfoSource::LocalPath(path) => format!("本地路径 {}", path.display()),
OfficialServerInfoSource::OfficialFile(file_name) => { OfficialServerInfoSource::OfficialFile(file_name) => {
format!("official file {file_name}") format!("官方文件 {file_name}")
} }
OfficialServerInfoSource::OfficialUrl(url) => format!("official URL {url}"), OfficialServerInfoSource::OfficialUrl(url) => format!("官方 URL {url}"),
} }
} }
@@ -1017,17 +1005,14 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
match event.kind { match event.kind {
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new( OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
"download", "download",
format!("({}/{}) started {}", event.index, event.total, event.url), format!("({}/{}) 开始 {}", event.index, event.total, event.url),
), ),
OfficialResourcePullProgressKind::Finished => { OfficialResourcePullProgressKind::Finished => {
let status = event let status = event.status.map(localized_pull_status).unwrap_or("未知");
.status
.map(|status| status.as_str())
.unwrap_or("unknown");
OfficialUpdateProgress::new( OfficialUpdateProgress::new(
"download", "download",
format!( format!(
"({}/{}) finished status={} bytes={} transferred_bytes={} {}", "({}/{}) 完成 状态={} 文件字节={} 本轮传输字节={} {}",
event.index, event.index,
event.total, event.total,
status, status,
@@ -1040,6 +1025,14 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
} }
} }
fn localized_pull_status(status: crate::OfficialResourcePullStatus) -> &'static str {
match status {
crate::OfficialResourcePullStatus::SkippedExisting => "已复用",
crate::OfficialResourcePullStatus::Resumed => "已续传",
crate::OfficialResourcePullStatus::Downloaded => "已下载",
}
}
/// Computes the extended snapshot delta. /// Computes the extended snapshot delta.
pub fn diff_extended_snapshot( pub fn diff_extended_snapshot(
current: &OfficialUpdateSnapshot, current: &OfficialUpdateSnapshot,
@@ -1074,7 +1067,7 @@ pub fn read_snapshot(path: &Path) -> anyhow::Result<Option<OfficialUpdateSnapsho
let legacy = let legacy =
serde_json::from_slice::<YostarJpSyncSnapshot>(&data).map_err(|legacy_error| { serde_json::from_slice::<YostarJpSyncSnapshot>(&data).map_err(|legacy_error| {
anyhow::anyhow!( anyhow::anyhow!(
"failed to parse official update snapshot as v2 ({update_error}) or legacy v1 ({legacy_error})" "无法解析官方更新快照:v2 解析失败 ({update_error})legacy v1 解析也失败 ({legacy_error})"
) )
})?; })?;
Ok(Some(OfficialUpdateSnapshot::new(legacy, Vec::new(), None))) Ok(Some(OfficialUpdateSnapshot::new(legacy, Vec::new(), None)))
@@ -1178,7 +1171,7 @@ fn resolve_bootstrap(
); );
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"launcher", "launcher",
"fetching official launcher game config and remote manifest", "正在拉取官方启动器游戏配置和远端 manifest",
)); ));
let (game_config, manifest_url, manifest) = launcher let (game_config, manifest_url, manifest) = launcher
.fetch_latest_remote_manifest() .fetch_latest_remote_manifest()
@@ -1189,14 +1182,14 @@ fn resolve_bootstrap(
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"launcher", "launcher",
format!( format!(
"launcher metadata resolved: latest_version={} manifest_files={}", "启动器元数据已解析:最新版本={} manifest 文件数={}",
launcher_metadata.game_latest_version, launcher_metadata.manifest_file_count launcher_metadata.game_latest_version, launcher_metadata.manifest_file_count
), ),
)); ));
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"bootstrap-cache", "bootstrap-cache",
format!("checking bootstrap cache {}", cache_path.display()), format!("检查启动缓存 {}", cache_path.display()),
)); ));
if let Some(cache) = read_bootstrap_cache(cache_path)? { if let Some(cache) = read_bootstrap_cache(cache_path)? {
if let Some(game_main_config) = if let Some(game_main_config) =
@@ -1204,7 +1197,7 @@ fn resolve_bootstrap(
{ {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"bootstrap-cache", "bootstrap-cache",
"cache hit; reusing parsed GameMainConfig", "命中缓存;复用已解析的 GameMainConfig",
)); ));
return Ok(ResolvedBootstrap { return Ok(ResolvedBootstrap {
launcher_metadata, launcher_metadata,
@@ -1216,7 +1209,7 @@ fn resolve_bootstrap(
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"game-main-config", "game-main-config",
"cache miss; fetching resources.assets and parsing GameMainConfig", "缓存未命中;拉取 resources.assets 并解析 GameMainConfig",
)); ));
check_shutdown_requested(should_cancel)?; check_shutdown_requested(should_cancel)?;
let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands( let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands(
@@ -1249,12 +1242,12 @@ fn resolve_bootstrap(
)?; )?;
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"bootstrap-cache", "bootstrap-cache",
format!("bootstrap cache written {}", cache_path.display()), format!("启动缓存已写入 {}", cache_path.display()),
)); ));
} else { } else {
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"bootstrap-cache", "bootstrap-cache",
"dry-run: bootstrap cache not written", "试运行:不写入启动缓存",
)); ));
} }
@@ -1287,7 +1280,7 @@ fn fetch_seed_catalogs(
progress(OfficialUpdateProgress::new( progress(OfficialUpdateProgress::new(
"catalog", "catalog",
format!( format!(
"fetching seed catalog {}{} {}", "拉取种子目录 {}{} {}",
endpoint_kind_label(endpoint.kind), endpoint_kind_label(endpoint.kind),
platform_suffix(endpoint.platform), platform_suffix(endpoint.platform),
endpoint.url endpoint.url
@@ -1310,21 +1303,18 @@ fn build_inventory_from_seed_catalogs(
let table_catalog = catalogs let table_catalog = catalogs
.table_catalog .table_catalog
.as_deref() .as_deref()
.ok_or_else(|| anyhow::anyhow!("official discovery did not include TableCatalog.bytes"))?; .ok_or_else(|| anyhow::anyhow!("官方发现结果缺少 TableCatalog.bytes"))?;
let mut platform_catalogs = Vec::new(); let mut platform_catalogs = Vec::new();
for platform in platforms { for platform in platforms {
let bundle_packing_info = catalogs.bundle_packing_infos.get(platform).ok_or_else(|| { let bundle_packing_info = catalogs.bundle_packing_infos.get(platform).ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"official discovery did not include {} BundlePackingInfo.bytes", "官方发现结果缺少 {} BundlePackingInfo.bytes",
platform.as_str() platform.as_str()
) )
})?; })?;
let media_catalog = catalogs.media_catalogs.get(platform).ok_or_else(|| { let media_catalog = catalogs.media_catalogs.get(platform).ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!("官方发现结果缺少 {} MediaCatalog.bytes", platform.as_str())
"official discovery did not include {} MediaCatalog.bytes",
platform.as_str()
)
})?; })?;
platform_catalogs.push(YostarJpPlatformCatalogInventory::from_catalog_bytes( platform_catalogs.push(YostarJpPlatformCatalogInventory::from_catalog_bytes(
@@ -1375,7 +1365,7 @@ impl SeedCatalogs {
fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result<PatchPlatform> { fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result<PatchPlatform> {
endpoint.platform.ok_or_else(|| { endpoint.platform.ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"discovery endpoint {:?} has no platform: {}", "发现 endpoint {:?} 缺少平台:{}",
endpoint.kind, endpoint.kind,
endpoint.url endpoint.url
) )
@@ -1403,13 +1393,13 @@ impl OfficialUpdateLock {
continue; continue;
} }
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"official update output is locked: {}", "官方资源目录已被锁定 (locked){}",
path.display() path.display()
)); ));
} }
Err(error) => { Err(error) => {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"failed to acquire official update lock {}: {error}", "获取官方资源目录状态锁失败 {}{error}",
path.display() path.display()
)); ));
} }
@@ -1417,7 +1407,7 @@ impl OfficialUpdateLock {
} }
Err(anyhow::anyhow!( Err(anyhow::anyhow!(
"failed to acquire official update lock {}", "获取官方资源目录状态锁失败 {}",
path.display() path.display()
)) ))
} }
@@ -1639,7 +1629,7 @@ mod tests {
let first = OfficialUpdateLock::acquire(&config).unwrap(); let first = OfficialUpdateLock::acquire(&config).unwrap();
let second = OfficialUpdateLock::acquire(&config).unwrap_err(); let second = OfficialUpdateLock::acquire(&config).unwrap_err();
assert!(second.to_string().contains("locked")); assert!(second.to_string().contains("锁定"));
drop(first); drop(first);
assert!(OfficialUpdateLock::acquire(&config).is_ok()); assert!(OfficialUpdateLock::acquire(&config).is_ok());
} }
@@ -1681,7 +1671,7 @@ mod tests {
) )
.unwrap_err(); .unwrap_err();
assert!(error.to_string().contains("shutdown request")); assert!(error.to_string().contains("停止请求"));
assert_eq!(checks, 1); assert_eq!(checks, 1);
} }
} }
@@ -180,9 +180,9 @@ fn official_update_service_emits_human_progress_events() {
assert!(events.iter().any(|event| event.stage == "catalog")); assert!(events.iter().any(|event| event.stage == "catalog"));
assert!(events.iter().any(|event| event.stage == "dry-run")); assert!(events.iter().any(|event| event.stage == "dry-run"));
assert!(events.iter().any(|event| event.stage == "finish")); assert!(events.iter().any(|event| event.stage == "finish"));
assert!(events.iter().any(|event| event assert!(events
.message .iter()
.contains("pull plan contains 19 official URLs"))); .any(|event| event.message.contains("拉取计划包含 19 个官方 URL")));
} }
#[test] #[test]
@@ -201,14 +201,14 @@ fn official_update_first_run_pulls_without_pre_audit() {
assert_eq!(report.skipped_count, 0); assert_eq!(report.skipped_count, 0);
assert_eq!(report.local_manifest_repair_needed_count, 0); assert_eq!(report.local_manifest_repair_needed_count, 0);
assert!(events.iter().any(|event| { assert!(events.iter().any(|event| {
event.stage == "local-state" && event.message.contains("has_local_resources=false") event.stage == "local-state" && event.message.contains("是否已有本地资源=false")
})); }));
assert!(events assert!(events
.iter() .iter()
.any(|event| { event.stage == "audit" && event.message.contains("skipping local audit") })); .any(|event| { event.stage == "audit" && event.message.contains("跳过本地审计") }));
assert!(events.iter().any(|event| { assert!(events
event.stage == "decision" && event.message.contains("initial_pull_needed=true") .iter()
})); .any(|event| { event.stage == "decision" && event.message.contains("首次拉取=true") }));
} }
#[test] #[test]
@@ -227,13 +227,13 @@ fn official_update_second_run_audits_existing_resources_before_reuse() {
assert_eq!(report.local_manifest_verified_count, 19); assert_eq!(report.local_manifest_verified_count, 19);
assert_eq!(report.local_manifest_repair_needed_count, 0); assert_eq!(report.local_manifest_repair_needed_count, 0);
assert!(events.iter().any(|event| { assert!(events.iter().any(|event| {
event.stage == "local-state" && event.message.contains("has_local_resources=true") event.stage == "local-state" && event.message.contains("是否已有本地资源=true")
})); }));
assert!(events.iter().any(|event| { assert!(events.iter().any(|event| {
event.stage == "audit" && event.message.contains("auditing local download manifest") event.stage == "audit" && event.message.contains("审计本地下载 manifest")
})); }));
assert!(events.iter().any(|event| { assert!(events.iter().any(|event| {
event.stage == "decision" && event.message.contains("initial_pull_needed=false") event.stage == "decision" && event.message.contains("首次拉取=false")
})); }));
} }