feat: track official versions and import resources

Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures.

Fixes #13

Fixes #12

Fixes #8
This commit is contained in:
2026-07-15 19:53:10 +08:00
parent 4192d7ee4c
commit 90307a3243
30 changed files with 1269 additions and 157 deletions
@@ -379,7 +379,7 @@ fn main() -> anyhow::Result<()> {
let pull_plan = pull_plan
.as_ref()
.ok_or_else(|| anyhow::anyhow!("download requested but no pull plan exists"))?;
let report = fetcher.pull(&pull_plan).map_err(anyhow::Error::msg)?;
let report = fetcher.pull(pull_plan).map_err(anyhow::Error::msg)?;
let final_audit = fetcher
.audit_local_manifest(pull_plan)
.map_err(anyhow::Error::msg)?;
+93 -76
View File
@@ -1,9 +1,9 @@
use bat_adapters::official::yostar_jp::PatchPlatform;
use bat_infrastructure::{
lexical_absolute, open_append_file, read_file_no_symlink, validate_output_root,
validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource, OfficialUpdateConfig,
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus,
OfficialVerificationSummary, PRIVATE_FILE_MODE,
lexical_absolute, open_append_file, read_file_no_symlink, read_version_state,
validate_output_root, validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionState, PRIVATE_FILE_MODE,
};
use serde::{Deserialize, Serialize};
use std::env;
@@ -608,17 +608,7 @@ fn record_daemon_status(
return;
}
if let Err(error) = update_daemon_status(
state_dir,
update.state,
update.last_update_status,
update.last_error,
update.next_retry_seconds,
update.last_success_unix_seconds,
update.next_check_unix_seconds,
update.pending_scheduled_force,
update.next_forced_refresh_at,
) {
if let Err(error) = update_daemon_status(state_dir, update) {
logger.log_text("daemon", format!("更新后台状态失败:{error}"));
}
}
@@ -731,10 +721,7 @@ fn classify_pid_lock_file(path: &Path) -> anyhow::Result<PidLockState> {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Ok(PidLockState::Corrupt);
}
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) => return Err(error.into()),
};
let contents = fs::read_to_string(path)?;
let Some(pid) = parse_pid_value(&contents) else {
return Ok(PidLockState::Corrupt);
};
@@ -1322,6 +1309,8 @@ struct DaemonStatusReport {
current_stage: Option<String>,
current_message: Option<String>,
download_progress: Option<DaemonDownloadProgress>,
version_state_path: Option<PathBuf>,
version_state: Option<OfficialVersionState>,
pending_scheduled_force: Option<bool>,
next_forced_refresh_unix_seconds: Option<u64>,
command: Option<Vec<String>>,
@@ -1600,6 +1589,14 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
.unwrap_or_else(|| daemon_structured_log_path(state_dir));
let structured_log_exists = path_exists_no_follow(&structured_log_path)?;
let rotated_structured_log_paths = rotated_structured_log_paths(&structured_log_path);
let version_state_path = status_file.as_ref().map(|status| {
status
.resource_output_root
.join("official-version-state.json")
});
let version_state = version_state_path
.as_ref()
.and_then(|path| read_version_state(path).ok().flatten());
Ok(DaemonStatusReport {
status: if running { "running" } else { "stopped" },
@@ -1659,6 +1656,8 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
download_progress: status_file
.as_ref()
.and_then(|status| status.download_progress.clone()),
version_state_path,
version_state,
pending_scheduled_force: status_file
.as_ref()
.map(|status| status.pending_scheduled_force),
@@ -1896,6 +1895,8 @@ fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
print_json_field(value, "current_stage", "当前阶段");
print_json_field(value, "current_message", "当前消息");
print_json_field(value, "download_progress", "下载进度");
print_json_field(value, "version_state_path", "版本状态路径");
print_json_field(value, "version_state", "版本状态");
print_json_field(value, "resource_output_root", "资源目录");
print_json_field(value, "state_dir", "状态目录");
print_json_field(value, "socket_path", "socket");
@@ -2102,6 +2103,7 @@ impl HumanReport for OfficialUpdateReport {
print_path_field("输出根目录", &self.output_root);
print_path_field("active release", &self.active_resource_root);
print_path_field("current", &self.current_path);
print_path_field("version state", &self.version_state_path);
print_optional_path_field("staging", self.staging_path.as_ref());
print_optional_path_field("published", self.published_version_path.as_ref());
print_path_field("snapshot", &self.snapshot_path);
@@ -2174,7 +2176,32 @@ impl HumanReport for DaemonStatusReport {
if let Some(progress) = self.download_progress.as_ref() {
print_field("下载进度", format_daemon_download_progress(progress));
}
if let Some(version_state) = self.version_state.as_ref() {
print_optional_field(
"当前完成版本",
version_state
.current_completed_version
.as_ref()
.map(|version| version.id.as_str()),
);
print_optional_field(
"正在拉取版本",
version_state
.in_progress_version
.as_ref()
.map(|version| version.id.as_str()),
);
print_optional_field(
"上一个可用版本",
version_state
.previous_available_version
.as_ref()
.map(|version| version.id.as_str()),
);
print_field("失败版本数", version_state.failed_versions.len());
}
print_optional_path_field("资源目录", self.resource_output_root.as_ref());
print_optional_path_field("版本状态", self.version_state_path.as_ref());
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_optional_path_field("日志", self.log_path.as_ref());
@@ -2499,29 +2526,26 @@ struct DoctorReport {
}
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
let mut checks = Vec::new();
checks.push(path_check(
"state_dir",
&options.state_dir,
"后台状态目录可用",
));
checks.push(path_check(
"output_root",
&options.config.output_root,
"资源输出目录可用",
));
checks.push(safety_check(
"output_root_safety",
validate_output_root(&options.config.output_root),
"资源输出目录安全边界通过",
));
checks.push(safety_check(
"state_dir_safety",
validate_runtime_state_dir(&options.state_dir),
"后台状态目录安全边界通过",
));
checks.push(command_check("curl", &options.config.curl_command));
checks.push(command_check("unzip", &options.config.unzip_command));
let mut checks = vec![
path_check("state_dir", &options.state_dir, "后台状态目录可用"),
path_check(
"output_root",
&options.config.output_root,
"资源输出目录可用",
),
safety_check(
"output_root_safety",
validate_output_root(&options.config.output_root),
"资源输出目录安全边界通过",
),
safety_check(
"state_dir_safety",
validate_runtime_state_dir(&options.state_dir),
"后台状态目录安全边界通过",
),
command_check("curl", &options.config.curl_command),
command_check("unzip", &options.config.unzip_command),
];
let pid_path = daemon_pid_path(&options.state_dir);
let daemon_running = read_pid_file(&pid_path)
@@ -2864,17 +2888,7 @@ fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::R
Ok(())
}
fn update_daemon_status(
state_dir: &Path,
state: &str,
last_update_status: Option<String>,
last_error: Option<String>,
next_retry_seconds: Option<u64>,
last_success_unix_seconds: Option<u64>,
next_check_unix_seconds: Option<u64>,
pending_scheduled_force: bool,
next_forced_refresh_at: SystemTime,
) -> anyhow::Result<()> {
fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> anyhow::Result<()> {
let status_path = daemon_status_path(state_dir);
let mut status = read_daemon_status_file(&status_path)?.unwrap_or_else(|| DaemonStatusFile {
version: DAEMON_STATUS_VERSION,
@@ -2899,22 +2913,23 @@ fn update_daemon_status(
command: env::args().collect(),
});
status.pid = std::process::id();
status.state = state.to_string();
status.state = update.state.to_string();
status.updated_unix_seconds = unix_seconds_now();
status.last_update_status = last_update_status;
status.last_error = last_error;
status.next_retry_seconds = next_retry_seconds;
if last_success_unix_seconds.is_some() {
status.last_success_unix_seconds = last_success_unix_seconds;
status.last_update_status = update.last_update_status;
status.last_error = update.last_error;
status.next_retry_seconds = update.next_retry_seconds;
if update.last_success_unix_seconds.is_some() {
status.last_success_unix_seconds = update.last_success_unix_seconds;
}
status.next_check_unix_seconds = next_check_unix_seconds;
if state != "running" {
status.next_check_unix_seconds = update.next_check_unix_seconds;
if update.state != "running" {
status.current_stage = None;
status.current_message = None;
status.download_progress = None;
}
status.pending_scheduled_force = pending_scheduled_force;
status.next_forced_refresh_unix_seconds = system_time_to_unix_seconds(next_forced_refresh_at);
status.pending_scheduled_force = update.pending_scheduled_force;
status.next_forced_refresh_unix_seconds =
system_time_to_unix_seconds(update.next_forced_refresh_at);
write_daemon_status_file(&status_path, &status)
}
@@ -3130,11 +3145,11 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
fn format_duration_arg(duration: Duration) -> String {
let millis = duration.as_millis();
if millis % 3_600_000 == 0 {
if millis.is_multiple_of(3_600_000) {
format!("{}h", millis / 3_600_000)
} else if millis % 60_000 == 0 {
} else if millis.is_multiple_of(60_000) {
format!("{}m", millis / 60_000)
} else if millis % 1_000 == 0 {
} else if millis.is_multiple_of(1_000) {
format!("{}s", millis / 1_000)
} else {
format!("{millis}ms")
@@ -3825,7 +3840,7 @@ fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result
"verify 只验证本地资源,不能和 --force 同时使用"
));
}
if options.config.repair == false && options.config.audit_local == false {
if !options.config.repair && !options.config.audit_local {
return Err(anyhow::anyhow!(
"verify 必须启用本地审计;不要同时传 --no-repair --no-audit-local"
));
@@ -3854,7 +3869,7 @@ fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()>
}
fn tools_are_non_default(config: &OfficialUpdateConfig) -> bool {
config.curl_command != PathBuf::from("curl") || config.unzip_command != PathBuf::from("unzip")
config.curl_command != Path::new("curl") || config.unzip_command != Path::new("unzip")
}
fn next_option_value(
@@ -4503,14 +4518,16 @@ mod tests {
let next_check = unix_seconds_after(Duration::from_secs(30));
update_daemon_status(
&state_dir,
"sleeping",
Some("downloaded".to_string()),
None,
Some(30),
Some(123456),
Some(next_check),
false,
SystemTime::now() + Duration::from_secs(3600),
DaemonStatusUpdate {
state: "sleeping",
last_update_status: Some("downloaded".to_string()),
last_error: None,
next_retry_seconds: Some(30),
last_success_unix_seconds: Some(123456),
next_check_unix_seconds: Some(next_check),
pending_scheduled_force: false,
next_forced_refresh_at: SystemTime::now() + Duration::from_secs(3600),
},
)
.unwrap();
let report = build_daemon_status_report(&state_dir).unwrap();
+235 -28
View File
@@ -4,6 +4,7 @@ use bat_adapters::manifest::GenericManifest;
use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry};
use bat_core::domain::{Resource, ResourceType};
use bat_core::repositories::{CasRepository, ResourceRepository};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// 待导入的 AssetBundle 数据。
@@ -32,12 +33,47 @@ pub struct ImportedResource {
pub id: String,
/// Manifest 中的资源路径。
pub source_path: String,
/// Manifest 或路径推断出的资源类型。
pub resource_type: ResourceType,
/// 导入链路使用的稳定分类标签。
pub category: ResourceImportCategory,
/// CAS 返回的对象 ID。
pub object_id: String,
/// 写入 CAS 的字节数。
pub bytes: u64,
/// UnityFS 解析摘要。
pub unityfs: UnityFsImportSummary,
/// UnityFS 解析摘要,仅 AssetBundle 资源会填充
pub unityfs: Option<UnityFsImportSummary>,
}
/// Stable resource category produced by the import pipeline.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ResourceImportCategory {
/// UnityFS AssetBundle resource.
AssetBundle,
/// Unity TextAsset or text-like payload.
TextAsset,
/// Table bundle/database resource.
Table,
/// Media payload such as audio, video, texture, or media archive.
Media,
/// Manifest/catalog sidecar.
Manifest,
/// Resource that does not match a known category yet.
Other,
}
impl ResourceImportCategory {
/// Returns a stable lowercase label for reports and golden tests.
pub fn as_str(self) -> &'static str {
match self {
Self::AssetBundle => "asset_bundle",
Self::TextAsset => "text_asset",
Self::Table => "table",
Self::Media => "media",
Self::Manifest => "manifest",
Self::Other => "other",
}
}
}
/// 导入时解析到的 UnityFS 基础结构摘要。
@@ -58,8 +94,10 @@ pub struct UnityFsImportSummary {
pub struct ResourceImportReport {
/// 成功导入的资源。
pub imported: Vec<ImportedResource>,
/// 被跳过的非 AssetBundle 资源路径。
/// 被跳过的资源路径。
pub skipped: Vec<String>,
/// 按稳定分类标签统计的成功导入数量。
pub category_counts: BTreeMap<String, usize>,
}
/// 将解析后的资源清单写入 CAS 与资源仓储的应用服务。
@@ -88,10 +126,12 @@ impl<'a> ResourceImportService<'a> {
}
}
/// 导入 manifest 中的 AssetBundle 资源
/// 导入 manifest 中可用的资源数据
///
/// AssetBundle 条目会记录到 `skipped`。AssetBundle 条目必须能在
/// `bundles` 中按完整路径或文件名找到对应数据,否则返回错误。
/// AssetBundle 条目必须能在 `bundles` 中按完整路径或文件名找到对应
/// 数据并成功解析 UnityFS,否则返回错误。TextAsset/Table/Media 等
/// 非 AssetBundle 条目在有数据时写入 CAS 和 `ResourceRepository`;缺少
/// 数据时记录到 `skipped`,用于渐进式导入官方下载结果。
pub async fn import_manifest_bundles(
&self,
manifest: &GenericManifest,
@@ -100,18 +140,23 @@ impl<'a> ResourceImportService<'a> {
let mut report = ResourceImportReport::default();
for entry in &manifest.resources {
if entry.resource_type != ResourceType::AssetBundle {
let category = classify_import_category(entry.resource_type, &entry.path);
let Some(bundle) = find_bundle(bundles, &entry.path) else {
if entry.resource_type == ResourceType::AssetBundle {
return Err(bat_core::Error::InvalidArgument(format!(
"Missing bundle data for manifest resource: {}",
entry.path
)));
}
report.skipped.push(entry.path.clone());
continue;
}
};
let bundle = find_bundle(bundles, &entry.path).ok_or_else(|| {
bat_core::Error::InvalidArgument(format!(
"Missing bundle data for manifest resource: {}",
entry.path
))
})?;
let unityfs = self.parse_unityfs_summary(&entry.path, bundle).await?;
let unityfs = if entry.resource_type == ResourceType::AssetBundle {
Some(self.parse_unityfs_summary(&entry.path, bundle).await?)
} else {
None
};
let object_id = self.cas.store(&bundle.data).await?;
let mut stored_entry = entry.clone();
@@ -128,10 +173,16 @@ impl<'a> ResourceImportService<'a> {
report.imported.push(ImportedResource {
id,
source_path: entry.path.clone(),
resource_type: entry.resource_type,
category,
object_id,
bytes: bundle.data.len() as u64,
unityfs,
});
*report
.category_counts
.entry(category.as_str().to_string())
.or_insert(0) += 1;
}
Ok(report)
@@ -196,6 +247,43 @@ fn file_name(path: &str) -> Option<&str> {
Path::new(path).file_name()?.to_str()
}
fn classify_import_category(resource_type: ResourceType, path: &str) -> ResourceImportCategory {
match resource_type {
ResourceType::AssetBundle => ResourceImportCategory::AssetBundle,
ResourceType::TextAsset => ResourceImportCategory::TextAsset,
ResourceType::TableBundle => ResourceImportCategory::Table,
ResourceType::Media => ResourceImportCategory::Media,
ResourceType::Manifest => ResourceImportCategory::Manifest,
ResourceType::Other => classify_import_category_from_path(path),
}
}
fn classify_import_category_from_path(path: &str) -> ResourceImportCategory {
let normalized = path.replace('\\', "/").to_ascii_lowercase();
if normalized.contains("tablebundles/") {
ResourceImportCategory::Table
} else if normalized.contains("mediaresources/")
|| normalized.contains("mediaresources-")
|| matches!(
normalized.rsplit('.').next(),
Some("mp3" | "mp4" | "ogg" | "wav" | "png" | "jpg" | "jpeg" | "webp" | "acb" | "awb")
)
{
ResourceImportCategory::Media
} else if normalized.contains("textassets/")
|| matches!(
normalized.rsplit('.').next(),
Some("txt" | "csv" | "xml" | "yaml" | "yml")
)
{
ResourceImportCategory::TextAsset
} else if normalized.contains("catalog") || normalized.ends_with(".hash") {
ResourceImportCategory::Manifest
} else {
ResourceImportCategory::Other
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -287,6 +375,30 @@ mod tests {
address: None,
dependencies: Vec::new(),
},
ResourceEntry {
path: "TextAssets/dialogue.csv".to_string(),
hash: "synthetic-text-hash".to_string(),
size: 1,
resource_type: ResourceType::TextAsset,
address: Some("dialogue".to_string()),
dependencies: Vec::new(),
},
ResourceEntry {
path: "TableBundles/ExcelDB.db".to_string(),
hash: "synthetic-table-hash".to_string(),
size: 1,
resource_type: ResourceType::TableBundle,
address: None,
dependencies: Vec::new(),
},
ResourceEntry {
path: "MediaResources-Windows/voice/title.acb".to_string(),
hash: "synthetic-media-hash".to_string(),
size: 1,
resource_type: ResourceType::Media,
address: None,
dependencies: Vec::new(),
},
],
metadata: ManifestMetadata {
locator_id: None,
@@ -306,32 +418,78 @@ mod tests {
let report = service
.import_manifest_bundles(
&synthetic_manifest(),
&[BundleSource::new(
"minimal.bundle",
synthetic_minimal_unityfs_bundle(),
)],
&[
BundleSource::new("minimal.bundle", synthetic_minimal_unityfs_bundle()),
BundleSource::new("dialogue.csv", b"id,text\n1,hello".to_vec()),
BundleSource::new("ExcelDB.db", b"table-fixture".to_vec()),
BundleSource::new("title.acb", b"media-fixture".to_vec()),
],
)
.await
.unwrap();
assert_eq!(report.imported.len(), 1);
assert_eq!(report.imported.len(), 4);
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
assert!(cas.exists(&report.imported[0].object_id).await);
assert_eq!(report.imported[0].unityfs.unity_version, "2021.3.56f2");
assert_eq!(report.imported[0].unityfs.block_count, 1);
assert_eq!(report.imported[0].unityfs.directory_count, 1);
assert_eq!(report.imported[0].resource_type, ResourceType::AssetBundle);
assert_eq!(
report.imported[0].unityfs.directories,
vec!["SYNTHETIC-CAB".to_string()]
report.imported[0].category,
ResourceImportCategory::AssetBundle
);
let unityfs = report.imported[0].unityfs.as_ref().unwrap();
assert_eq!(unityfs.unity_version, "2021.3.56f2");
assert_eq!(unityfs.block_count, 1);
assert_eq!(unityfs.directory_count, 1);
assert_eq!(unityfs.directories, vec!["SYNTHETIC-CAB".to_string()]);
assert_eq!(
report.imported[1].category,
ResourceImportCategory::TextAsset
);
assert_eq!(report.imported[2].category, ResourceImportCategory::Table);
assert_eq!(report.imported[3].category, ResourceImportCategory::Media);
assert_eq!(report.category_counts.get("asset_bundle"), Some(&1));
assert_eq!(report.category_counts.get("text_asset"), Some(&1));
assert_eq!(report.category_counts.get("table"), Some(&1));
assert_eq!(report.category_counts.get("media"), Some(&1));
let indexed = resources.list(ResourceQuery::all()).await.unwrap();
assert_eq!(indexed.len(), 1);
assert_eq!(indexed[0].entry.hash, report.imported[0].object_id);
assert_eq!(indexed.len(), 4);
let indexed_bundle = indexed
.iter()
.find(|resource| resource.entry.path == "synthetic/minimal.bundle")
.unwrap();
assert_eq!(indexed_bundle.entry.hash, report.imported[0].object_id);
assert_eq!(
indexed[0].entry.size,
indexed_bundle.entry.size,
synthetic_minimal_unityfs_bundle().len() as u64
);
assert_eq!(
indexed
.iter()
.find(|resource| resource.entry.path.ends_with("title.acb"))
.unwrap()
.entry
.resource_type,
ResourceType::Media
);
assert_eq!(
indexed
.iter()
.find(|resource| resource.entry.path.ends_with("ExcelDB.db"))
.unwrap()
.entry
.resource_type,
ResourceType::TableBundle
);
assert_eq!(
indexed
.iter()
.find(|resource| resource.entry.path.ends_with("dialogue.csv"))
.unwrap()
.entry
.resource_type,
ResourceType::TextAsset
);
}
#[tokio::test]
@@ -370,4 +528,53 @@ mod tests {
assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
assert_eq!(resources.count(ResourceQuery::all()).await.unwrap(), 0);
}
#[tokio::test]
async fn indexes_non_bundle_resources_without_unityfs_summary() {
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let mut manifest = synthetic_manifest();
manifest
.resources
.retain(|entry| entry.resource_type != ResourceType::AssetBundle);
let report = service
.import_manifest_bundles(
&manifest,
&[
BundleSource::new("dialogue.csv", b"id,text\n1,hello".to_vec()),
BundleSource::new("ExcelDB.db", b"table-fixture".to_vec()),
BundleSource::new("title.acb", b"media-fixture".to_vec()),
],
)
.await
.unwrap();
assert_eq!(report.imported.len(), 3);
assert!(report.imported.iter().all(|item| item.unityfs.is_none()));
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
assert_eq!(
resources
.count(ResourceQuery::by_type(ResourceType::TextAsset))
.await
.unwrap(),
1
);
assert_eq!(
resources
.count(ResourceQuery::by_type(ResourceType::TableBundle))
.await
.unwrap(),
1
);
assert_eq!(
resources
.count(ResourceQuery::by_type(ResourceType::Media))
.await
.unwrap(),
1
);
}
}
+11 -6
View File
@@ -24,7 +24,10 @@ pub mod resources;
mod zip_validation;
pub use cas::FileSystemCasRepository;
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
pub use import::{
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
ResourceImportService,
};
pub use official_download::{
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem,
@@ -48,11 +51,13 @@ pub use official_sync::{
};
pub use official_update::{
cached_game_main_config_for_metadata, diff_extended_snapshot, read_bootstrap_cache,
read_snapshot, write_bootstrap_cache, write_snapshot, ExtendedSnapshotDelta,
GameMainConfigSnapshot, LauncherMetadataSnapshot, OfficialBootstrapCache,
OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialServerInfoSource,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, ResolvedBootstrap,
read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot, write_version_state,
ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot,
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState,
ResolvedBootstrap,
};
pub use path_security::{
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
+53
View File
@@ -2787,6 +2787,59 @@ exit 22
assert!(service.read_download_manifest().unwrap().entries.is_empty());
}
#[test]
fn official_http_failure_regression_fixtures_are_enforced() {
for fixture in [
include_str!("../tests/fixtures/official_regression/http_403.json"),
include_str!("../tests/fixtures/official_regression/http_404.json"),
] {
let fixture: serde_json::Value = serde_json::from_str(fixture).unwrap();
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
let status = fixture["http_status"].as_u64().unwrap() as u16;
write_fake_http_error_curl(&curl_path, status);
let service =
OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path)
.with_retry_attempts(3);
let url = fixture["url"].as_str().unwrap();
let error = service.pull(&one_url_plan(url)).unwrap_err();
let expected_kind = fixture["failure_kind"].as_str().unwrap();
let expected_retryable = fixture["retryable"].as_bool().unwrap();
let expected_attempts = fixture["expected_attempts"].as_u64().unwrap();
assert!(error.contains(expected_kind));
assert!(error.contains(&format!("retryable={expected_retryable}")));
assert_eq!(
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
expected_attempts.to_string()
);
let quarantine = service.read_download_quarantine().unwrap();
let entry = quarantine.entries.get(url).unwrap();
assert_eq!(entry.failure_kind.as_deref(), Some(expected_kind));
assert_eq!(entry.retryable, Some(expected_retryable));
assert_eq!(entry.attempts, expected_attempts as usize);
}
}
#[test]
fn official_hash_mismatch_regression_fixture_is_enforced() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../tests/fixtures/official_regression/hash_mismatch_catalog.json"
))
.unwrap();
let error = verify_official_seed_catalog_hash(
fixture["data_url"].as_str().unwrap(),
fixture["data"].as_str().unwrap().as_bytes(),
fixture["hash_url"].as_str().unwrap(),
fixture["official_hash"].as_str().unwrap().as_bytes(),
)
.unwrap_err();
assert!(error.contains(fixture["expected_error_contains"].as_str().unwrap()));
}
#[test]
fn retries_http_5xx_before_quarantine() {
let out_dir = TempDir::new().unwrap();
+499 -3
View File
@@ -37,11 +37,14 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub const OFFICIAL_UPDATE_SNAPSHOT_VERSION: u32 = 2;
/// Current official bootstrap cache schema version.
pub const OFFICIAL_BOOTSTRAP_CACHE_VERSION: u32 = 1;
/// Current official version-state schema version.
pub const OFFICIAL_VERSION_STATE_VERSION: u32 = 1;
const OFFICIAL_CURRENT_LINK: &str = "current";
const OFFICIAL_VERSIONS_DIR: &str = "versions";
const OFFICIAL_STAGING_DIR: &str = ".staging";
const OFFICIAL_DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
const OFFICIAL_SYNC_SNAPSHOT_FILE: &str = "official-sync-snapshot.json";
const OFFICIAL_VERSION_STATE_FILE: &str = "official-version-state.json";
/// Server-info input for an official update run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -125,6 +128,11 @@ impl OfficialUpdateConfig {
self.output_root.join("official-bootstrap-cache.json")
}
/// Returns the persistent version-state path for this config.
pub fn version_state_path(&self) -> PathBuf {
self.output_root.join(OFFICIAL_VERSION_STATE_FILE)
}
/// Returns the lock path used for non-dry-run executions.
pub fn lock_path(&self) -> PathBuf {
self.output_root.join(".official-sync.lock")
@@ -181,6 +189,82 @@ pub struct OfficialUpdateSnapshot {
pub game_main_config_bootstrap: Option<GameMainConfigSnapshot>,
}
/// Persistent version state for an official resource output root.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialVersionState {
/// State schema version.
#[serde(default = "default_version_state_version")]
pub state_version: u32,
/// Last successfully published version.
#[serde(default)]
pub current_completed_version: Option<OfficialVersionRecord>,
/// Version currently being downloaded into staging.
#[serde(default)]
pub in_progress_version: Option<OfficialVersionRecord>,
/// Previously usable version before the latest successful publish.
#[serde(default)]
pub previous_available_version: Option<OfficialVersionRecord>,
/// Recent versions that failed after entering staging.
#[serde(default)]
pub failed_versions: Vec<OfficialFailedVersionRecord>,
/// Last time this state file was updated.
pub updated_unix_seconds: u64,
}
impl Default for OfficialVersionState {
fn default() -> Self {
Self {
state_version: OFFICIAL_VERSION_STATE_VERSION,
current_completed_version: None,
in_progress_version: None,
previous_available_version: None,
failed_versions: Vec::new(),
updated_unix_seconds: unix_seconds_now(),
}
}
}
/// One version tracked by `official-version-state.json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialVersionRecord {
/// Stable publish ID under `versions/<id>`.
pub id: String,
/// Selected app version.
pub app_version: String,
/// Selected bundle version, when present.
pub bundle_version: Option<String>,
/// Selected Addressables root.
pub addressables_root: String,
/// Resource root for this version. For in-progress versions this is a
/// staging path; for completed versions it is a versioned path.
pub resource_root: PathBuf,
/// Snapshot path associated with the resource root.
pub snapshot_path: PathBuf,
/// Staging path, when the version is still being downloaded.
#[serde(default)]
pub staging_path: Option<PathBuf>,
/// Versioned path, when known.
#[serde(default)]
pub version_path: Option<PathBuf>,
/// Start time for a staged pull.
#[serde(default)]
pub started_unix_seconds: Option<u64>,
/// Completion time for a published version.
#[serde(default)]
pub completed_unix_seconds: Option<u64>,
}
/// Failed version entry tracked after a staged pull or publish error.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialFailedVersionRecord {
/// Version that failed.
pub version: OfficialVersionRecord,
/// Human-readable failure reason.
pub error: String,
/// Failure time.
pub failed_unix_seconds: u64,
}
impl OfficialUpdateSnapshot {
/// Creates an update snapshot from a base sync snapshot and extended data.
pub fn new(
@@ -391,6 +475,8 @@ pub struct OfficialUpdateReport {
pub active_resource_root: PathBuf,
/// Atomic `current` pointer path.
pub current_path: PathBuf,
/// Persistent version-state path.
pub version_state_path: PathBuf,
/// Versioned release directory after a successful publish.
pub published_version_path: Option<PathBuf>,
/// Staging directory used during download before publish.
@@ -754,6 +840,10 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new("lock", "资源目录状态锁已获取"));
Some(lock)
};
let version_state_path = config.version_state_path();
if !config.dry_run {
recover_interrupted_version_state(&version_state_path)?;
}
check_shutdown_requested(&mut should_cancel)?;
let publish_layout = OfficialPublishLayout::new(&config.output_root);
@@ -1035,6 +1125,7 @@ impl OfficialUpdateService {
output_root: config.output_root.clone(),
active_resource_root: active_resource_root.clone(),
current_path: publish_layout.current_path.clone(),
version_state_path: version_state_path.clone(),
published_version_path: None,
staging_path: None,
snapshot_path: snapshot_path.clone(),
@@ -1079,6 +1170,27 @@ impl OfficialUpdateService {
"audit",
verification_progress_message(&report.verification_summary),
));
if !config.dry_run {
let current_record = version_record_for_snapshot(
&current_update_snapshot,
VersionRecordInput {
id: version_id_from_path(&active_resource_root)
.unwrap_or_else(|| fallback_version_id(&current_update_snapshot)),
resource_root: active_resource_root.clone(),
snapshot_path: snapshot_path.clone(),
staging_path: None,
version_path: Some(active_resource_root.clone()),
started_unix_seconds: None,
completed_unix_seconds: Some(unix_seconds_now()),
},
);
complete_version_state(
&version_state_path,
current_record,
&active_resource_root,
&snapshot_path,
)?;
}
progress(OfficialUpdateProgress::new(
"finish",
"资源已是最新;无需下载",
@@ -1129,11 +1241,22 @@ impl OfficialUpdateService {
publish_layout
.seed_staging_from_active(&active_resource_root, &publish_plan.staging_path)
.map_err(anyhow::Error::msg)?;
let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path);
let in_progress_record = prepare_in_progress_version_state(
&version_state_path,
&current_update_snapshot,
&publish_plan.id,
&active_resource_root,
previous_snapshot.as_ref(),
&publish_plan.staging_path,
&staging_snapshot_path,
)?;
let mut version_state_guard =
VersionStateGuard::new(version_state_path.clone(), in_progress_record);
let staging_fetcher = OfficialResourcePullService::with_curl_command(
&publish_plan.staging_path,
&config.curl_command,
);
let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path);
report.staging_path = Some(publish_plan.staging_path.clone());
report.snapshot_path = staging_snapshot_path.clone();
report.download_manifest = staging_fetcher.download_manifest_path();
@@ -1150,7 +1273,10 @@ impl OfficialUpdateService {
},
&mut should_cancel,
)
.map_err(anyhow::Error::msg)?;
.map_err(|error| anyhow::anyhow!(error))
.inspect_err(|error| {
let _ = version_state_guard.fail(&error.to_string());
})?;
progress(OfficialUpdateProgress::new(
"download",
format!(
@@ -1217,7 +1343,15 @@ impl OfficialUpdateService {
report.local_manifest_verified_count = final_audit.verified_count();
report.local_manifest_repair_needed_count = final_audit.repair_needed_count();
report.verification_summary = final_verification_summary;
report.snapshot_written = Some(final_snapshot_path);
report.snapshot_written = Some(final_snapshot_path.clone());
let completed_record = version_state_guard.record()?.clone();
complete_version_state(
&version_state_path,
completed_record,
&published_version_path,
&final_snapshot_path,
)?;
version_state_guard.commit();
progress(OfficialUpdateProgress::new(
"finish",
@@ -1505,6 +1639,11 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
&config.bootstrap_cache_path(),
"官方启动缓存",
)?;
ensure_safe_file_target(
&config.output_root,
&config.version_state_path(),
"官方版本状态",
)?;
ensure_safe_file_target(&config.output_root, &config.lock_path(), "官方同步锁")?;
Ok(())
}
@@ -1725,6 +1864,30 @@ pub fn write_snapshot(path: &Path, snapshot: &OfficialUpdateSnapshot) -> anyhow:
Ok(())
}
/// Reads the persistent official version state.
pub fn read_version_state(path: &Path) -> anyhow::Result<Option<OfficialVersionState>> {
let Some(data) = read_file_no_symlink(path, "官方版本状态").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
let state: OfficialVersionState = serde_json::from_slice(&data)?;
if state.state_version != OFFICIAL_VERSION_STATE_VERSION {
return Err(anyhow::anyhow!(
"不支持的官方版本状态 schema:{},当前版本={}",
state.state_version,
OFFICIAL_VERSION_STATE_VERSION
));
}
Ok(Some(state))
}
/// Writes the persistent official version state atomically.
pub fn write_version_state(path: &Path, state: &OfficialVersionState) -> anyhow::Result<()> {
let data = serde_json::to_vec_pretty(state)?;
write_file_atomic(path, &data, STATE_FILE_MODE, "官方版本状态").map_err(anyhow::Error::msg)?;
Ok(())
}
/// Reads an official bootstrap cache from disk.
pub fn read_bootstrap_cache(path: &Path) -> anyhow::Result<Option<OfficialBootstrapCache>> {
let Some(data) = read_file_no_symlink(path, "官方启动缓存").map_err(anyhow::Error::msg)?
@@ -1763,6 +1926,227 @@ fn default_bootstrap_cache_version() -> u32 {
OFFICIAL_BOOTSTRAP_CACHE_VERSION
}
fn default_version_state_version() -> u32 {
OFFICIAL_VERSION_STATE_VERSION
}
#[derive(Debug)]
struct VersionRecordInput {
id: String,
resource_root: PathBuf,
snapshot_path: PathBuf,
staging_path: Option<PathBuf>,
version_path: Option<PathBuf>,
started_unix_seconds: Option<u64>,
completed_unix_seconds: Option<u64>,
}
fn version_record_for_snapshot(
snapshot: &OfficialUpdateSnapshot,
input: VersionRecordInput,
) -> OfficialVersionRecord {
OfficialVersionRecord {
id: input.id,
app_version: snapshot.app_version.clone(),
bundle_version: snapshot.bundle_version.clone(),
addressables_root: snapshot.addressables_root.clone(),
resource_root: input.resource_root,
snapshot_path: input.snapshot_path,
staging_path: input.staging_path,
version_path: input.version_path,
started_unix_seconds: input.started_unix_seconds,
completed_unix_seconds: input.completed_unix_seconds,
}
}
fn recover_interrupted_version_state(path: &Path) -> anyhow::Result<()> {
let Some(mut state) = read_version_state(path)? else {
return Ok(());
};
let Some(version) = state.in_progress_version.take() else {
return Ok(());
};
state.failed_versions.push(OfficialFailedVersionRecord {
version,
error: "上一次官方资源同步在完成发布前中断".to_string(),
failed_unix_seconds: unix_seconds_now(),
});
trim_failed_versions(&mut state.failed_versions);
state.updated_unix_seconds = unix_seconds_now();
write_version_state(path, &state)
}
fn prepare_in_progress_version_state(
path: &Path,
snapshot: &OfficialUpdateSnapshot,
publish_id: &str,
active_resource_root: &Path,
previous_snapshot: Option<&OfficialUpdateSnapshot>,
staging_path: &Path,
snapshot_path: &Path,
) -> anyhow::Result<OfficialVersionRecord> {
let mut state = read_version_state(path)?.unwrap_or_default();
let fallback_current = previous_snapshot.map(|snapshot| {
version_record_for_snapshot(
snapshot,
VersionRecordInput {
id: version_id_from_path(active_resource_root)
.unwrap_or_else(|| fallback_version_id(snapshot)),
resource_root: active_resource_root.to_path_buf(),
snapshot_path: snapshot_path_for_state_path(snapshot, active_resource_root),
staging_path: None,
version_path: Some(active_resource_root.to_path_buf()),
started_unix_seconds: None,
completed_unix_seconds: Some(unix_seconds_now()),
},
)
});
if state.current_completed_version.is_none() {
state.current_completed_version = fallback_current;
}
let now = unix_seconds_now();
let record = version_record_for_snapshot(
snapshot,
VersionRecordInput {
id: publish_id.to_string(),
resource_root: staging_path.to_path_buf(),
snapshot_path: snapshot_path.to_path_buf(),
staging_path: Some(staging_path.to_path_buf()),
version_path: None,
started_unix_seconds: Some(now),
completed_unix_seconds: None,
},
);
state.in_progress_version = Some(record.clone());
state.updated_unix_seconds = now;
write_version_state(path, &state)?;
Ok(record)
}
fn complete_version_state(
path: &Path,
record: OfficialVersionRecord,
published_path: &Path,
snapshot_path: &Path,
) -> anyhow::Result<()> {
let mut state = read_version_state(path)?.unwrap_or_default();
let previous = state
.current_completed_version
.take()
.filter(|previous| previous.id != record.id);
if previous.is_some() {
state.previous_available_version = previous;
}
state.current_completed_version = Some(OfficialVersionRecord {
resource_root: published_path.to_path_buf(),
snapshot_path: snapshot_path.to_path_buf(),
staging_path: None,
version_path: Some(published_path.to_path_buf()),
completed_unix_seconds: Some(unix_seconds_now()),
..record
});
state.in_progress_version = None;
state.updated_unix_seconds = unix_seconds_now();
write_version_state(path, &state)
}
fn fail_version_state(
path: &Path,
record: OfficialVersionRecord,
error: &str,
) -> anyhow::Result<()> {
let mut state = read_version_state(path)?.unwrap_or_default();
state.in_progress_version = None;
state.failed_versions.push(OfficialFailedVersionRecord {
version: record,
error: error.to_string(),
failed_unix_seconds: unix_seconds_now(),
});
trim_failed_versions(&mut state.failed_versions);
state.updated_unix_seconds = unix_seconds_now();
write_version_state(path, &state)
}
struct VersionStateGuard {
path: PathBuf,
record: Option<OfficialVersionRecord>,
}
impl VersionStateGuard {
fn new(path: PathBuf, record: OfficialVersionRecord) -> Self {
Self {
path,
record: Some(record),
}
}
fn record(&self) -> anyhow::Result<&OfficialVersionRecord> {
self.record
.as_ref()
.ok_or_else(|| anyhow::anyhow!("官方版本状态事务已结束"))
}
fn fail(&mut self, error: &str) -> anyhow::Result<()> {
let Some(record) = self.record.take() else {
return Ok(());
};
fail_version_state(&self.path, record, error)
}
fn commit(&mut self) {
self.record = None;
}
}
impl Drop for VersionStateGuard {
fn drop(&mut self) {
let Some(record) = self.record.take() else {
return;
};
let _ = fail_version_state(
&self.path,
record,
"官方资源同步未完成;已将该版本记录为失败,保留当前可用版本",
);
}
}
fn trim_failed_versions(failed_versions: &mut Vec<OfficialFailedVersionRecord>) {
const MAX_FAILED_VERSIONS: usize = 8;
if failed_versions.len() > MAX_FAILED_VERSIONS {
let drop_count = failed_versions.len() - MAX_FAILED_VERSIONS;
failed_versions.drain(0..drop_count);
}
}
fn snapshot_path_for_state_path(
_snapshot: &OfficialUpdateSnapshot,
resource_root: &Path,
) -> PathBuf {
resource_root.join(OFFICIAL_SYNC_SNAPSHOT_FILE)
}
fn version_id_from_path(path: &Path) -> Option<String> {
path.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn fallback_version_id(snapshot: &OfficialUpdateSnapshot) -> String {
format!(
"{}-{}",
sanitize_publish_segment(&snapshot.app_version),
snapshot
.bundle_version
.as_deref()
.map(sanitize_publish_segment)
.unwrap_or_else(|| "no-bundle".to_string())
)
}
fn launcher_metadata_from_parts(
launcher_version: &str,
game_config: &YostarJpLauncherGameConfig,
@@ -2268,6 +2652,118 @@ mod tests {
assert_eq!(read_bootstrap_cache(&path).unwrap(), Some(cache));
}
#[test]
fn persists_version_state_json_round_trip() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("official-version-state.json");
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
let current = version_record_for_snapshot(
&snapshot,
VersionRecordInput {
id: "1.70.0-bundle-current".to_string(),
resource_root: temp.path().join("versions/current"),
snapshot_path: temp
.path()
.join("versions/current/official-sync-snapshot.json"),
staging_path: None,
version_path: Some(temp.path().join("versions/current")),
started_unix_seconds: None,
completed_unix_seconds: Some(10),
},
);
let failed = OfficialFailedVersionRecord {
version: current.clone(),
error: "fixture failure".to_string(),
failed_unix_seconds: 11,
};
let state = OfficialVersionState {
current_completed_version: Some(current),
failed_versions: vec![failed],
updated_unix_seconds: 12,
..OfficialVersionState::default()
};
write_version_state(&path, &state).unwrap();
assert_eq!(read_version_state(&path).unwrap(), Some(state));
}
#[test]
fn version_state_tracks_in_progress_success_and_failure() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("official-version-state.json");
let active = temp.path().join("versions/previous");
let staging = temp.path().join(".staging/current");
let staging_snapshot = staging.join("official-sync-snapshot.json");
let published = temp.path().join("versions/current");
let previous_snapshot = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("previous")],
None,
);
let current_snapshot = OfficialUpdateSnapshot::new(
fixture_base_snapshot(),
vec![fixture_marker("current")],
Some(&fixture_bootstrap()),
);
let in_progress = prepare_in_progress_version_state(
&path,
&current_snapshot,
"current",
&active,
Some(&previous_snapshot),
&staging,
&staging_snapshot,
)
.unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert_eq!(state.in_progress_version.as_ref().unwrap().id, "current");
assert_eq!(
state.current_completed_version.as_ref().unwrap().id,
"previous"
);
complete_version_state(
&path,
in_progress.clone(),
&published,
&published.join("official-sync-snapshot.json"),
)
.unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert_eq!(
state.current_completed_version.as_ref().unwrap().id,
"current"
);
assert!(state.in_progress_version.is_none());
assert_eq!(
state.previous_available_version.as_ref().unwrap().id,
"previous"
);
let failed = prepare_in_progress_version_state(
&path,
&current_snapshot,
"broken",
&published,
state
.current_completed_version
.as_ref()
.map(|_| &current_snapshot),
&temp.path().join(".staging/broken"),
&temp
.path()
.join(".staging/broken/official-sync-snapshot.json"),
)
.unwrap();
fail_version_state(&path, failed, "download 403").unwrap();
let state = read_version_state(&path).unwrap().unwrap();
assert!(state.in_progress_version.is_none());
assert_eq!(state.failed_versions.len(), 1);
assert_eq!(state.failed_versions[0].version.id, "broken");
assert!(state.failed_versions[0].error.contains("403"));
}
#[test]
fn update_rejects_dangerous_output_root_before_network_work() {
let config = OfficialUpdateConfig {
+4
View File
@@ -187,6 +187,8 @@ impl SqliteResourceRepository {
ResourceType::AssetBundle => "AssetBundle",
ResourceType::Manifest => "Manifest",
ResourceType::TableBundle => "TableBundle",
ResourceType::TextAsset => "TextAsset",
ResourceType::Media => "Media",
ResourceType::Other => "Other",
}
}
@@ -196,6 +198,8 @@ impl SqliteResourceRepository {
"AssetBundle" => Ok(ResourceType::AssetBundle),
"Manifest" => Ok(ResourceType::Manifest),
"TableBundle" => Ok(ResourceType::TableBundle),
"TextAsset" => Ok(ResourceType::TextAsset),
"Media" => Ok(ResourceType::Media),
"Other" => Ok(ResourceType::Other),
other => Err(bat_core::Error::Serialization(format!(
"Unknown resource type: {}",
@@ -0,0 +1,7 @@
{
"data_url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes",
"hash_url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.hash",
"data": "ExcelDB.db",
"official_hash": "0",
"expected_error_contains": "官方 hash 校验失败"
}
@@ -0,0 +1,7 @@
{
"url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/Forbidden.bytes",
"http_status": 403,
"failure_kind": "http_forbidden",
"retryable": false,
"expected_attempts": 1
}
@@ -0,0 +1,7 @@
{
"url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/Missing.bytes",
"http_status": 404,
"failure_kind": "http_not_found",
"retryable": false,
"expected_attempts": 1
}
@@ -2,6 +2,8 @@
"imported": [
{
"bytes": 143,
"category": "asset_bundle",
"resource_type": "AssetBundle",
"source_path": "synthetic/minimal.bundle",
"unityfs": {
"block_count": 1,
@@ -13,6 +15,9 @@
}
}
],
"category_counts": {
"asset_bundle": 1
},
"resource_count": 1,
"skipped": [
"synthetic/catalog.json"
@@ -7,9 +7,9 @@ use bat_adapters::official::yostar_jp::{
YostarJpResourceDiscoveryPlan, YostarJpResourceEndpointKind, YostarJpServerInfo,
};
use bat_infrastructure::{
build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService,
OfficialResourcePullService, OfficialUpdateConfig, OfficialUpdateProgress,
OfficialUpdateService, OfficialUpdateStatus,
build_official_pull_plan_from_platform_inventory, read_version_state,
OfficialGameMainConfigBootstrapService, OfficialResourcePullService, OfficialUpdateConfig,
OfficialUpdateProgress, OfficialUpdateService, OfficialUpdateStatus,
};
use std::collections::HashMap;
use std::fs;
@@ -252,15 +252,26 @@ fn official_update_first_run_pulls_without_pre_audit() {
.unwrap()
.file_type()
.is_symlink());
assert_eq!(
config
.output_root
.join("official-download-manifest.json")
.exists(),
false
);
assert!(!config
.output_root
.join("official-download-manifest.json")
.exists());
assert!(published.join("official-download-manifest.json").exists());
assert!(published.join("official-sync-snapshot.json").exists());
let version_state = read_version_state(&report.version_state_path)
.unwrap()
.unwrap();
assert_eq!(
version_state
.current_completed_version
.as_ref()
.unwrap()
.version_path
.as_deref(),
Some(published.as_path())
);
assert!(version_state.in_progress_version.is_none());
assert!(version_state.failed_versions.is_empty());
}
#[test]
@@ -305,6 +316,18 @@ fn official_update_second_run_audits_existing_resources_before_reuse() {
.unwrap()
.file_type()
.is_symlink());
let version_state = read_version_state(&report.version_state_path)
.unwrap()
.unwrap();
assert_eq!(
version_state
.current_completed_version
.as_ref()
.unwrap()
.resource_root,
report.active_resource_root
);
assert!(version_state.in_progress_version.is_none());
}
struct TestHarness {
@@ -99,17 +99,20 @@ async fn imports_synthetic_addressables_catalog_bundle_against_golden() {
"imported": report.imported.iter().map(|imported| {
json!({
"bytes": imported.bytes,
"category": imported.category.as_str(),
"resource_type": format!("{:?}", imported.resource_type),
"source_path": imported.source_path,
"unityfs": {
"block_count": imported.unityfs.block_count,
"directories": imported.unityfs.directories,
"directory_count": imported.unityfs.directory_count,
"unity_version": imported.unityfs.unity_version,
}
"unityfs": imported.unityfs.as_ref().map(|unityfs| json!({
"block_count": unityfs.block_count,
"directories": unityfs.directories,
"directory_count": unityfs.directory_count,
"unity_version": unityfs.unity_version,
})),
})
}).collect::<Vec<_>>(),
"resource_count": indexed.len(),
"skipped": report.skipped,
"category_counts": report.category_counts,
});
let expected: serde_json::Value =
serde_json::from_str(include_str!("golden/synthetic_phase2_import.json")).unwrap();