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
+186 -85
View File
@@ -226,6 +226,19 @@ impl OfficialLocalManifestAuditStatus {
pub fn is_verified(self) -> bool {
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.
@@ -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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OfficialLocalResourceState {
@@ -362,7 +412,7 @@ impl OfficialResourcePullService {
) -> Result<OfficialResourcePullReport, String> {
fs::create_dir_all(&self.output_root).map_err(|error| {
format!(
"Failed to create output root {}: {error}",
"创建资源输出目录失败 {}{error}",
self.output_root.display()
)
})?;
@@ -378,7 +428,7 @@ impl OfficialResourcePullService {
let mut processed_urls = HashSet::<String>::new();
for (offset, url) in urls.into_iter().enumerate() {
if should_cancel() {
return Err("official resource pull interrupted by shutdown request".to_string());
return Err("官方资源拉取已被停止请求中断".to_string());
}
let index = offset + 1;
@@ -389,16 +439,13 @@ impl OfficialResourcePullService {
));
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)?;
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create destination directory {}: {error}",
parent.display()
)
format!("创建下载目标目录失败 {}{error}", parent.display())
})?;
}
@@ -443,7 +490,7 @@ impl OfficialResourcePullService {
}
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)?;
@@ -473,6 +520,42 @@ impl OfficialResourcePullService {
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
/// 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.
@@ -508,7 +591,7 @@ impl OfficialResourcePullService {
/// `MediaCatalog.bytes`.
pub fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
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);
@@ -517,12 +600,12 @@ impl OfficialResourcePullService {
match self.fetch_bytes_once(url) {
Ok(bytes) => return Ok(bytes),
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> {
@@ -536,14 +619,14 @@ impl OfficialResourcePullService {
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
"启动 curl 命令失败 {}{error}",
self.curl_command.display()
)
})?;
if !output.status.success() {
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)
@@ -559,9 +642,7 @@ impl OfficialResourcePullService {
let _ = fs::remove_file(&partial);
self.download_one(url, &partial, false)
.map_err(|retry_error| {
format!(
"resume failed for {url}: {error}; clean retry also failed: {retry_error}"
)
format!("续传失败 {url}{error};清理后重新下载也失败:{retry_error}")
})?;
}
} else {
@@ -570,28 +651,20 @@ impl OfficialResourcePullService {
if file_len_if_exists(destination)?.is_some() {
fs::remove_file(destination).map_err(|error| {
format!(
"Failed to replace existing destination {}: {error}",
destination.display()
)
format!("替换已有目标文件失败 {}{error}", destination.display())
})?;
}
fs::rename(&partial, destination).map_err(|error| {
format!(
"Failed to move partial download {} -> {}: {error}",
"移动临时下载文件失败 {} -> {}{error}",
partial.display(),
destination.display()
)
})?;
let bytes = fs::metadata(destination)
.map_err(|error| {
format!(
"Downloaded file missing at {}: {error}",
destination.display()
)
})?
.map_err(|error| format!("下载完成后目标文件缺失 {}{error}", destination.display()))?
.len();
Ok(PullOneResult {
@@ -612,17 +685,13 @@ impl OfficialResourcePullService {
match self.download_one_once(url, destination, resume) {
Ok(()) => return Ok(()),
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",
destination.display()
)
}))
Err(last_error
.unwrap_or_else(|| format!("curl 未执行就已失败:{url} -> {}", destination.display())))
}
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| {
format!(
"Failed to launch curl command {}: {error}",
"启动 curl 命令失败 {}{error}",
self.curl_command.display()
)
})?;
@@ -649,7 +718,7 @@ impl OfficialResourcePullService {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"curl failed for {url} -> {}: {}",
"curl 下载失败 {url} -> {}{}",
destination.display(),
stderr.trim()
));
@@ -687,7 +756,7 @@ impl OfficialResourcePullService {
self.invalidate_download_manifest_hash_pair(manifest, pair)
{
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 data = fs::read(&data_path).map_err(|error| {
format!(
"Failed to read official hash data file {}: {error}",
"读取官方 hash 对应数据文件失败 {}{error}",
data_path.display()
)
})?;
let hash = fs::read(&hash_path).map_err(|error| {
format!(
"Failed to read official hash sidecar {}: {error}",
"读取官方 hash sidecar 失败 {}{error}",
hash_path.display()
)
})?;
@@ -748,7 +817,7 @@ impl OfficialResourcePullService {
for pair in pairs {
if !verified_hash_urls.contains(&pair.hash_url) {
return Err(format!(
"official hash pair was not verified for {} using {}",
"官方 hash 对未完成校验:数据={} hash={}",
pair.data_url, pair.hash_url
));
}
@@ -766,23 +835,18 @@ impl OfficialResourcePullService {
}
Err(error) => {
return Err(format!(
"Failed to read download manifest {}: {error}",
"读取下载 manifest 失败 {}{error}",
path.display()
));
}
};
let manifest: OfficialDownloadManifest =
serde_json::from_slice(&bytes).map_err(|error| {
format!(
"Failed to parse download manifest {}: {error}",
path.display()
)
})?;
let manifest: OfficialDownloadManifest = serde_json::from_slice(&bytes)
.map_err(|error| format!("解析下载 manifest 失败 {}{error}", path.display()))?;
if manifest.version != DOWNLOAD_MANIFEST_VERSION {
return Err(format!(
"Unsupported download manifest version {} in {}",
"不支持的下载 manifest 版本 {},文件 {}",
manifest.version,
path.display()
));
@@ -795,29 +859,22 @@ impl OfficialResourcePullService {
let path = self.download_manifest_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create download manifest directory {}: {error}",
parent.display()
)
format!("创建下载 manifest 目录失败 {}{error}", parent.display())
})?;
}
let temporary = path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(manifest).map_err(|error| {
format!(
"Failed to serialize download manifest {}: {error}",
path.display()
)
})?;
let bytes = serde_json::to_vec_pretty(manifest)
.map_err(|error| format!("序列化下载 manifest 失败 {}{error}", path.display()))?;
fs::write(&temporary, bytes).map_err(|error| {
format!(
"Failed to write temporary download manifest {}: {error}",
"写入临时下载 manifest 失败 {}{error}",
temporary.display()
)
})?;
fs::rename(&temporary, &path).map_err(|error| {
format!(
"Failed to move download manifest {} -> {}: {error}",
"移动下载 manifest 失败 {} -> {}{error}",
temporary.display(),
path.display()
)
@@ -871,12 +928,7 @@ impl OfficialResourcePullService {
destination: &Path,
) -> Result<(), String> {
let bytes = fs::metadata(destination)
.map_err(|error| {
format!(
"Failed to inspect downloaded file {}: {error}",
destination.display()
)
})?
.map_err(|error| format!("读取已下载文件信息失败 {}{error}", destination.display()))?
.len();
let digest = blake3_file_hex(destination)?;
let relative_destination = self.relative_destination(destination)?;
@@ -899,7 +951,7 @@ impl OfficialResourcePullService {
.strip_prefix(&self.output_root)
.map_err(|error| {
format!(
"Destination {} is not under output root {}: {error}",
"目标路径 {} 不在输出目录 {} 下:{error}",
destination.display(),
self.output_root.display()
)
@@ -1013,15 +1065,15 @@ impl OfficialResourcePullService {
fn destination_for_url(&self, url: &str) -> Result<PathBuf, String> {
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
.strip_prefix("https://")
.ok_or_else(|| format!("Official URL must use https: {url}"))?;
.ok_or_else(|| format!("官方 URL 必须使用 https{url}"))?;
let (host, path) = rest
.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));
for segment in path.split('/') {
@@ -1029,7 +1081,7 @@ impl OfficialResourcePullService {
continue;
}
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);
@@ -1060,7 +1112,7 @@ pub fn verify_official_seed_catalog_hash(
if actual != expected {
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()
}
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> {
let mut urls = HashSet::new();
for pair in pairs {
@@ -1166,12 +1240,9 @@ struct PullOneResult {
fn file_len_if_exists(path: &Path) -> Result<Option<u64>, String> {
match fs::metadata(path) {
Ok(metadata) if metadata.is_file() => Ok(Some(metadata.len())),
Ok(_) => Err(format!(
"Expected file path but found non-file: {}",
path.display()
)),
Ok(_) => Err(format!("期望文件路径,但实际不是文件:{}", path.display())),
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> {
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 buffer = [0u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
.map_err(|error| format!("读取文件失败 {}{error}", path.display()))?;
if read == 0 {
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> {
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();
text.parse::<u32>().map_err(|error| {
format!("Official hash sidecar is not decimal xxHash32 at {hash_url}: {error}")
})
text.parse::<u32>()
.map_err(|error| format!("官方 hash sidecar 不是十进制 xxHash32{hash_url}{error}"))
}
fn xxhash32(bytes: &[u8]) -> u32 {
@@ -1649,6 +1719,37 @@ fi
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]
fn refreshes_official_hash_pairs_and_skips_manifest_verified_content() {
let out_dir = TempDir::new().unwrap();
@@ -1833,7 +1934,7 @@ fi
};
let error = service.pull(&plan).unwrap_err();
assert!(error.contains("official hash mismatch"));
assert!(error.contains("官方 hash 校验失败"));
let manifest = service.read_download_manifest().unwrap();
assert!(manifest.entries.is_empty());