//! Official JP resource update orchestration. //! //! This module is the production-facing form of the official update flow. It //! keeps the one-shot semantics required by external schedulers and Go CLI //! callers: discover current official state, compare remote markers, audit the //! local manifest, repair/download when needed, then return a structured report. use crate::path_security::{ ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, read_file_no_symlink, validate_output_root, write_file_atomic, STATE_FILE_MODE, }; use crate::{ build_official_pull_plan_for_platform_inventory, build_official_sync_plan, changed_endpoint_urls, default_official_platforms, OfficialGameMainConfigBootstrapService, OfficialLauncherBootstrapService, OfficialResourcePullPlan, OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullService, YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest, }; use bat_adapters::official::game_main_config::YostarJpGameMainConfig; use bat_adapters::official::inventory::{ YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory, }; use bat_adapters::official::yostar_jp::{ server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint, YostarJpResourceEndpointKind, YostarJpServerInfo, YostarJpSyncSnapshot, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs::{self, OpenOptions}; use std::io::Write; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; /// Current official update snapshot schema version. 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)] pub enum OfficialServerInfoSource { /// Read a local, already-audited official server-info JSON file. LocalPath(PathBuf), /// Fetch a server-info file by official file name. OfficialFile(String), /// Fetch a full official server-info URL. OfficialUrl(String), } /// Configuration for one official update execution. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OfficialUpdateConfig { /// Optional explicit server-info source. pub server_info_source: Option, /// Optional explicit connection group. pub connection_group: Option, /// Optional explicit app version. pub app_version: Option, /// Official launcher version used for signed metadata discovery. pub launcher_version: String, /// Whether to discover app version, server-info URL, and connection group /// from official metadata. pub auto_discover: bool, /// Platforms to update. Defaults to verified official platforms. pub platforms: Option>, /// Output root for resources and state files. pub output_root: PathBuf, /// Optional explicit sync snapshot path. pub snapshot_path: Option, /// Curl command used by the current infrastructure downloader. pub curl_command: PathBuf, /// Unzip command used when a metadata change requires GameMainConfig parsing. pub unzip_command: PathBuf, /// Dry run reports decisions and optional plan URLs without writing sync state. pub dry_run: bool, /// Include full download URLs when dry-running. pub plan: bool, /// Force download even when remote and local state look clean. pub force: bool, /// Audit the local download manifest before deciding up-to-date. pub audit_local: bool, /// Repair local files when the local manifest audit fails. pub repair: bool, } impl Default for OfficialUpdateConfig { fn default() -> Self { Self { server_info_source: None, connection_group: None, app_version: None, launcher_version: "1.7.2".to_string(), auto_discover: false, platforms: None, output_root: PathBuf::from("./bat-resources"), snapshot_path: None, curl_command: PathBuf::from("curl"), unzip_command: PathBuf::from("unzip"), dry_run: false, plan: false, force: false, audit_local: true, repair: true, } } } impl OfficialUpdateConfig { /// Returns the effective sync snapshot path for this config. pub fn effective_snapshot_path(&self) -> PathBuf { self.snapshot_path .clone() .unwrap_or_else(|| self.output_root.join("official-sync-snapshot.json")) } /// Returns the bootstrap cache path for this config. pub fn bootstrap_cache_path(&self) -> PathBuf { 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") } } /// Status of one official update execution. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OfficialUpdateStatus { /// Remote and local resources are already clean. UpToDate, /// Dry-run detected that a download would occur. WouldDownload, /// Resources were downloaded or repaired. Downloaded, } impl OfficialUpdateStatus { /// Returns a stable string label for CLI and JSON callers. pub fn as_str(self) -> &'static str { match self { Self::UpToDate => "up_to_date", Self::WouldDownload => "would_download", Self::Downloaded => "downloaded", } } } /// Snapshot of the official JP update state observed at a point in time. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialUpdateSnapshot { /// Snapshot schema version. #[serde(default = "default_update_snapshot_version")] pub snapshot_version: u32, /// Selected server-info connection group. pub connection_group_name: String, /// App version used for server-info override selection. pub app_version: String, /// Bundle version from server-info, when present. pub bundle_version: Option, /// Selected official Addressables root URL. pub addressables_root: String, /// Seed endpoints observed for the selected platform set. pub endpoints: Vec, /// Small remote marker contents fetched for change detection. #[serde(default)] pub endpoint_markers: Vec, /// Launcher metadata summary used for auto-discovery, when available. #[serde(default)] pub launcher_metadata: Option, /// Parsed GameMainConfig summary used for auto-discovery, when available. #[serde(default)] pub game_main_config_bootstrap: Option, } /// 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, /// Version currently being downloaded into staging. #[serde(default)] pub in_progress_version: Option, /// Previously usable version before the latest successful publish. #[serde(default)] pub previous_available_version: Option, /// Recent versions that failed after entering staging. #[serde(default)] pub failed_versions: Vec, /// 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/`. pub id: String, /// Selected app version. pub app_version: String, /// Selected bundle version, when present. pub bundle_version: Option, /// 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, /// Versioned path, when known. #[serde(default)] pub version_path: Option, /// Start time for a staged pull. #[serde(default)] pub started_unix_seconds: Option, /// Completion time for a published version. #[serde(default)] pub completed_unix_seconds: Option, } /// 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( base: YostarJpSyncSnapshot, endpoint_markers: Vec, bootstrap: Option<&ResolvedBootstrap>, ) -> Self { Self { snapshot_version: OFFICIAL_UPDATE_SNAPSHOT_VERSION, connection_group_name: base.connection_group_name, app_version: base.app_version, bundle_version: base.bundle_version, addressables_root: base.addressables_root, endpoints: base.endpoints, endpoint_markers, launcher_metadata: bootstrap.map(|bootstrap| bootstrap.launcher_metadata.clone()), game_main_config_bootstrap: bootstrap .map(|bootstrap| bootstrap.game_main_config.clone()), } } /// Returns the legacy base snapshot used by the existing sync planner. pub fn base_snapshot(&self) -> YostarJpSyncSnapshot { YostarJpSyncSnapshot { connection_group_name: self.connection_group_name.clone(), app_version: self.app_version.clone(), bundle_version: self.bundle_version.clone(), addressables_root: self.addressables_root.clone(), endpoints: self.endpoints.clone(), } } /// Returns the number of Addressables catalog markers checked. pub fn addressables_marker_checked_count(&self) -> usize { self.endpoint_markers .iter() .filter(|marker| marker.role == OfficialEndpointMarkerRole::AddressablesCatalogMarker) .count() } /// Returns marker count not used as strong content validation. pub fn unverified_marker_count(&self) -> usize { self.addressables_marker_checked_count() } } /// Captured value of one small remote marker endpoint. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialEndpointMarkerSnapshot { /// Endpoint kind. pub kind: YostarJpResourceEndpointKind, /// Platform for platform-specific endpoints. pub platform: Option, /// Official marker URL. pub url: String, /// How this marker should be interpreted. pub role: OfficialEndpointMarkerRole, /// Trimmed marker content. pub value: String, } /// Role of a fetched marker endpoint. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum OfficialEndpointMarkerRole { /// Official seed `.hash`, interpreted as xxHash32 decimal by download verification. OfficialSeedHash, /// Unity Addressables/SBP Hash128 marker, not a strong content validation. AddressablesCatalogMarker, } /// Summary of launcher metadata used by auto-discovery. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LauncherMetadataSnapshot { /// Launcher version used for signed requests. pub launcher_version: String, /// Latest game version reported by the launcher API. pub game_latest_version: String, /// Latest game file path reported by the launcher API. pub game_latest_file_path: String, /// Lowest game version accepted by the launcher API. pub game_lowest_version: Option, /// Game executable name. pub game_start_exe_name: Option, /// Game executable arguments. pub game_start_params: Vec, /// Remote launcher manifest URL. pub manifest_url: String, /// Remote launcher manifest source path. pub manifest_source: Option, /// Number of files in the remote launcher manifest. pub manifest_file_count: usize, } /// Summary of the decrypted GameMainConfig used by auto-discovery. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GameMainConfigSnapshot { /// Official server-info URL from GameMainConfig. pub server_info_data_url: Option, /// Default connection group from GameMainConfig. pub default_connection_group: Option, } /// Cached GameMainConfig summary keyed by launcher metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialBootstrapCache { /// Cache schema version. #[serde(default = "default_bootstrap_cache_version")] pub cache_version: u32, /// Launcher metadata that produced this GameMainConfig summary. pub launcher_metadata: LauncherMetadataSnapshot, /// Cached GameMainConfig summary. pub game_main_config: GameMainConfigSnapshot, } /// Resolved bootstrap data for one run. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedBootstrap { /// Launcher metadata summary. pub launcher_metadata: LauncherMetadataSnapshot, /// GameMainConfig summary. pub game_main_config: GameMainConfigSnapshot, /// Whether GameMainConfig came from the local cache. pub cache_hit: bool, } /// Extended snapshot delta not represented by the legacy sync planner. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct ExtendedSnapshotDelta { /// Whether fetched endpoint marker contents changed. pub endpoint_markers_changed: bool, /// Whether launcher metadata summary changed. pub launcher_metadata_changed: bool, /// Whether GameMainConfig summary changed. pub game_main_config_changed: bool, } impl ExtendedSnapshotDelta { /// Returns true when any extended snapshot field changed. pub fn has_changes(self) -> bool { self.endpoint_markers_changed || self.launcher_metadata_changed || self.game_main_config_changed } } /// Structured verification summary for CLI and JSON reports. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialVerificationSummary { /// Number of resources verified by the local download manifest's path, /// byte size, and BLAKE3 digest. pub local_manifest_blake3_verified_count: usize, /// Number of resources that still need local repair after manifest audit. pub local_manifest_repair_needed_count: usize, /// Human-facing description of the local manifest validation boundary. pub local_manifest_blake3_scope: String, /// Number of official seed `.bytes`/`.hash` pairs verified by the official /// decimal xxHash32 rule. pub official_hash_verified_count: usize, /// Human-facing description of the official hash validation boundary. pub official_hash_scope: String, /// Number of ZIP files that passed structural validation. pub zip_structure_verified_count: usize, /// Human-facing description of the ZIP structure validation boundary. pub zip_structure_scope: String, } impl OfficialVerificationSummary { /// Creates a verification summary from validation counters. pub fn new( local_manifest_blake3_verified_count: usize, local_manifest_repair_needed_count: usize, official_hash_verified_count: usize, zip_structure_verified_count: usize, ) -> Self { Self { local_manifest_blake3_verified_count, local_manifest_repair_needed_count, local_manifest_blake3_scope: "本地 download manifest 的 URL/path、size 和 BLAKE3 复用校验".to_string(), official_hash_verified_count, official_hash_scope: "官方 seed .bytes/.hash 对,按官方十进制 xxHash32(seed=0) 强校验" .to_string(), zip_structure_verified_count, zip_structure_scope: "所有 .zip 文件的 EOCD、central directory、local header 和边界结构校验".to_string(), } } } /// Structured report returned by an official update run. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialUpdateReport { /// Final update status. pub update_status: OfficialUpdateStatus, /// Selected connection group. pub connection_group: String, /// Selected app version. pub app_version: String, /// Selected bundle version. pub bundle_version: Option, /// Selected Addressables root. pub addressables_root: String, /// Platform set used by the run. pub platforms: Vec, /// Managed publish root containing `current`, `versions`, and staging. pub output_root: PathBuf, /// Active resource root used for local audit before this run. 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, /// Staging directory used during download before publish. pub staging_path: Option, /// Sync snapshot path. pub snapshot_path: PathBuf, /// Whether a previous snapshot existed. pub previous_snapshot_present: bool, /// Legacy sync decision label. pub decision: String, /// Whether force was enabled. pub force: bool, /// Whether local audit was enabled. pub audit_local: bool, /// Whether local repair was enabled. pub repair: bool, /// Whether a download was required. pub should_download: bool, /// Whether this was the first observed snapshot. pub is_initial: bool, /// Changed endpoint URLs from legacy diff. pub changed_endpoint_urls: Vec, /// Extended marker/metadata delta. pub extended_delta: ExtendedSnapshotDelta, /// Count of Addressables marker files checked. pub addressables_marker_checked_count: usize, /// Count of marker files not used for strong content validation. pub unverified_marker_count: usize, /// Whether bootstrap cache was hit. pub bootstrap_cache_hit: Option, /// Bootstrap cache path. pub bootstrap_cache_path: Option, /// Local manifest verified file count. pub local_manifest_verified_count: usize, /// Local manifest repair-needed file count. pub local_manifest_repair_needed_count: usize, /// Dry-run flag. pub dry_run: bool, /// Download URL count when plan output was requested. pub download_url_count: Option, /// Download URLs when plan output was requested. pub download_urls: Vec, /// Pull item count after a real download. pub resource_count: Option, /// Number of resources downloaded from scratch. pub downloaded_count: usize, /// Number of resources resumed. pub resumed_count: usize, /// Number of resources skipped using the local manifest. pub skipped_count: usize, /// Final local byte count in the pull report. pub final_bytes: u64, /// Bytes transferred in this run. pub transferred_bytes: u64, /// Number of official seed hashes verified. pub official_seed_hash_verified_count: usize, /// Verification summary with explicit boundaries for each validation type. pub verification_summary: OfficialVerificationSummary, /// Download manifest path. pub download_manifest: PathBuf, /// Snapshot path written after success. pub snapshot_written: Option, } /// Human-readable progress emitted while one official update run is executing. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OfficialUpdateProgress { /// Stable progress stage label. pub stage: &'static str, /// Human-readable status line. pub message: String, /// One-based download index when the event represents URL download work. pub download_index: Option, /// Total URL count for the current download plan. pub download_total: Option, /// Current official URL for download progress. pub download_url: Option, /// Stable pull status label when a URL finished. pub download_status: Option, /// Final local byte count for the URL when known. pub download_bytes: Option, /// Bytes transferred in this run for the URL when known. pub download_transferred_bytes: Option, /// Stable failure kind when a URL failed. pub download_failure_kind: Option, /// HTTP status parsed from curl stderr when a URL failed. pub download_failure_http_status: Option, /// Whether the final failure was retryable. pub download_failure_retryable: Option, /// Number of transfer attempts executed before failure. pub download_failure_attempts: Option, /// Whether the URL was recorded in the quarantine manifest. pub download_quarantined: Option, } impl OfficialUpdateProgress { /// Creates a progress event. pub fn new(stage: &'static str, message: impl Into) -> Self { Self { stage, message: message.into(), download_index: None, download_total: None, download_url: None, download_status: None, download_bytes: None, download_transferred_bytes: None, download_failure_kind: None, download_failure_http_status: None, download_failure_retryable: None, download_failure_attempts: None, download_quarantined: None, } } fn with_download_progress(mut self, event: &OfficialResourcePullProgress) -> Self { self.download_index = Some(event.index); self.download_total = Some(event.total); self.download_url = Some(event.url.clone()); self.download_status = event .status .map(|status| status.as_str().to_string()) .or_else(|| { (event.kind == OfficialResourcePullProgressKind::Failed) .then(|| "failed".to_string()) }); self.download_bytes = event.bytes; self.download_transferred_bytes = event.transferred_bytes; self.download_failure_kind = event.failure_kind.clone(); self.download_failure_http_status = event.failure_http_status; self.download_failure_retryable = event.failure_retryable; self.download_failure_attempts = event.failure_attempts; self.download_quarantined = (event.kind == OfficialResourcePullProgressKind::Failed).then_some(event.quarantined); self } } #[derive(Debug, Clone)] struct OfficialPublishLayout { root: PathBuf, current_path: PathBuf, versions_dir: PathBuf, staging_dir: PathBuf, } #[derive(Debug, Clone)] struct OfficialPublishPlan { id: String, staging_path: PathBuf, version_path: PathBuf, } impl OfficialPublishLayout { fn new(root: &Path) -> Self { Self { root: root.to_path_buf(), current_path: root.join(OFFICIAL_CURRENT_LINK), versions_dir: root.join(OFFICIAL_VERSIONS_DIR), staging_dir: root.join(OFFICIAL_STAGING_DIR), } } fn active_resource_root(&self) -> Result { if let Some(current_target) = self.current_target()? { return Ok(current_target); } // Legacy fallback for directories produced before atomic publishing. // The next non-dry-run update will seed staging from this tree and // publish it under `versions/` before switching `current`. Ok(self.root.clone()) } fn current_target(&self) -> Result, String> { let metadata = match fs::symlink_metadata(&self.current_path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => { return Err(format!( "读取 current 指针失败 {}:{error}", self.current_path.display() )) } }; if !metadata.file_type().is_symlink() { return Err(format!( "current 必须是指向 versioned 目录的 symlink:{}", self.current_path.display() )); } let target = fs::read_link(&self.current_path).map_err(|error| { format!( "读取 current symlink 目标失败 {}:{error}", self.current_path.display() ) })?; let target = if target.is_absolute() { target } else { self.root.join(target) }; ensure_path_within_root(&self.root, &target)?; ensure_safe_directory_path(&target, "current versioned 目录")?; Ok(Some(target)) } fn has_current_pointer(&self) -> Result { Ok(self.current_target()?.is_some()) } fn plan(&self, snapshot: &OfficialUpdateSnapshot) -> OfficialPublishPlan { let id = publish_id(snapshot); OfficialPublishPlan { staging_path: self.staging_dir.join(&id), version_path: self.versions_dir.join(&id), id, } } fn prepare_staging(&self, plan: &OfficialPublishPlan) -> Result<(), String> { validate_output_root(&self.root)?; ensure_safe_directory_path(&self.staging_dir, "官方资源 staging 根目录")?; fs::create_dir_all(&self.staging_dir).map_err(|error| { format!( "创建官方资源 staging 根目录失败 {}:{error}", self.staging_dir.display() ) })?; ensure_safe_directory_path(&self.staging_dir, "官方资源 staging 根目录")?; if path_exists_no_follow(&plan.staging_path)? { ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")?; fs::remove_dir_all(&plan.staging_path).map_err(|error| { format!( "清理官方资源 staging 目录失败 {}:{error}", plan.staging_path.display() ) })?; } if path_exists_no_follow(&plan.version_path)? { return Err(format!( "versioned 目录已存在,拒绝覆盖:{}", plan.version_path.display() )); } fs::create_dir_all(&plan.staging_path).map_err(|error| { format!( "创建官方资源 staging 目录失败 {}:{error}", plan.staging_path.display() ) })?; ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录") } fn seed_staging_from_active( &self, active_root: &Path, staging_root: &Path, ) -> Result<(), String> { if active_root == self.root && !self.legacy_manifest_exists()? { return Ok(()); } if !path_exists_no_follow(active_root)? { return Ok(()); } copy_tree_no_symlink(active_root, staging_root, active_root == self.root) } fn legacy_manifest_exists(&self) -> Result { path_exists_no_follow(&self.root.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE)) } fn publish(&self, plan: &OfficialPublishPlan) -> Result { ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?; fs::create_dir_all(&self.versions_dir).map_err(|error| { format!( "创建官方资源 versions 根目录失败 {}:{error}", self.versions_dir.display() ) })?; ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?; ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")?; if path_exists_no_follow(&plan.version_path)? { return Err(format!( "versioned 目录已存在,拒绝覆盖:{}", plan.version_path.display() )); } fs::rename(&plan.staging_path, &plan.version_path).map_err(|error| { format!( "发布官方资源版本目录失败 {} -> {}:{error}", plan.staging_path.display(), plan.version_path.display() ) })?; switch_current_symlink(&self.root, &self.current_path, &plan.id)?; Ok(plan.version_path.clone()) } } /// Official update runner. #[derive(Debug, Clone, Default)] pub struct OfficialUpdateService; impl OfficialUpdateService { /// Creates an official update runner. pub fn new() -> Self { Self } /// Executes one official update run. pub fn run(&self, config: &OfficialUpdateConfig) -> anyhow::Result { self.run_with_progress(config, |_| {}) } /// Executes one official update run and emits human-readable progress /// events. Structured reports are still returned separately. pub fn run_with_progress( &self, config: &OfficialUpdateConfig, mut progress: impl FnMut(OfficialUpdateProgress), ) -> anyhow::Result { self.run_with_progress_and_cancellation(config, &mut progress, || false) } /// Executes one official update run, emits progress events, and aborts /// between safe steps when `should_cancel` returns true. pub fn run_with_progress_and_cancellation( &self, config: &OfficialUpdateConfig, mut progress: impl FnMut(OfficialUpdateProgress), mut should_cancel: impl FnMut() -> bool, ) -> anyhow::Result { progress(OfficialUpdateProgress::new( "start", format!( "开始官方资源同步:资源目录={} 试运行={} 自动发现={}", config.output_root.display(), config.dry_run, config.auto_discover ), )); check_shutdown_requested(&mut should_cancel)?; validate_update_paths(config).map_err(anyhow::Error::msg)?; let _lock = if config.dry_run { progress(OfficialUpdateProgress::new("lock", "试运行:跳过状态锁")); None } else { progress(OfficialUpdateProgress::new( "lock", format!("获取资源目录状态锁 {}", config.lock_path().display()), )); let lock = OfficialUpdateLock::acquire(config)?; 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); let active_resource_root = publish_layout .active_resource_root() .map_err(anyhow::Error::msg)?; let has_current_pointer = publish_layout .has_current_pointer() .map_err(anyhow::Error::msg)?; let default_platforms = default_official_platforms(); let platforms = config .platforms .as_deref() .unwrap_or(default_platforms.as_slice()); let fetcher = OfficialResourcePullService::with_curl_command( &active_resource_root, &config.curl_command, ); let snapshot_path = snapshot_path_for(config, &active_resource_root); let bootstrap_cache_path = config.bootstrap_cache_path(); let bootstrap = if config.auto_discover { progress(OfficialUpdateProgress::new( "bootstrap", format!( "启用自动发现;启动器版本={} 缓存={}", config.launcher_version, bootstrap_cache_path.display() ), )); Some(resolve_bootstrap( &config.launcher_version, &config.curl_command, &config.unzip_command, &bootstrap_cache_path, !config.dry_run, &mut progress, &mut should_cancel, )?) } else { progress(OfficialUpdateProgress::new( "bootstrap", "未启用自动发现;使用显式元数据输入", )); None }; check_shutdown_requested(&mut should_cancel)?; let app_version = config .app_version .clone() .or_else(|| { bootstrap .as_ref() .map(|bootstrap| bootstrap.launcher_metadata.game_latest_version.clone()) }) .ok_or_else(|| { anyhow::anyhow!("缺少应用版本;请显式传入 --app-version 或启用 --auto-discover") })?; let connection_group = config .connection_group .clone() .or_else(|| { bootstrap.as_ref().and_then(|bootstrap| { bootstrap.game_main_config.default_connection_group.clone() }) }) .ok_or_else(|| { anyhow::anyhow!("缺少连接组;请显式传入 --connection-group 或启用 --auto-discover") })?; progress(OfficialUpdateProgress::new( "metadata", format!( "使用应用版本={} 连接组={} 平台={}", app_version, connection_group, platforms_label(platforms) ), )); let server_info_bytes = if let Some(source) = config.server_info_source.as_ref() { progress(OfficialUpdateProgress::new( "server-info", format!("从 {} 读取服务器信息", server_info_source_label(source)), )); load_server_info(source, &fetcher)? } else { let bootstrap = bootstrap.as_ref().ok_or_else(|| { anyhow::anyhow!( "缺少服务器信息来源;请显式传入 --server-info-* 或启用 --auto-discover" ) })?; let url = bootstrap .game_main_config .server_info_data_url .as_deref() .ok_or_else(|| anyhow::anyhow!("官方 GameMainConfig 中没有 ServerInfoDataUrl"))?; progress(OfficialUpdateProgress::new( "server-info", format!("拉取自动发现的服务器信息 {url}"), )); fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)? }; progress(OfficialUpdateProgress::new( "server-info", format!("解析服务器信息,字节数={}", server_info_bytes.len()), )); check_shutdown_requested(&mut should_cancel)?; let server_info = YostarJpServerInfo::from_slice(&server_info_bytes)?; let current_snapshot = server_info .sync_snapshot(&connection_group, &app_version, platforms) .map_err(anyhow::Error::msg)?; progress(OfficialUpdateProgress::new( "discovery", format!( "解析到 Addressables 根={} endpoint 数={}", current_snapshot.addressables_root, current_snapshot.endpoints.len() ), )); progress(OfficialUpdateProgress::new( "markers", format!( "检查 {} 个远端标记 endpoint", marker_endpoint_count(¤t_snapshot) ), )); let endpoint_markers = collect_endpoint_markers( &fetcher, ¤t_snapshot, &mut progress, &mut should_cancel, )?; let current_update_snapshot = OfficialUpdateSnapshot::new( current_snapshot.clone(), endpoint_markers, bootstrap.as_ref(), ); check_shutdown_requested(&mut should_cancel)?; progress(OfficialUpdateProgress::new( "snapshot", format!("读取上次快照 {}", snapshot_path.display()), )); let previous_snapshot = read_snapshot(&snapshot_path)?; let previous_base_snapshot = previous_snapshot .as_ref() .map(OfficialUpdateSnapshot::base_snapshot); let sync_plan = build_official_sync_plan(current_snapshot.clone(), previous_base_snapshot.as_ref()); let extended_delta = diff_extended_snapshot(¤t_update_snapshot, previous_snapshot.as_ref()); let changed_urls = changed_endpoint_urls(&sync_plan.delta); let remote_should_download = config.force || sync_plan.should_download() || extended_delta.has_changes(); progress(OfficialUpdateProgress::new( "decision", format!( "远端决策={:?} 强制刷新={} 远端需要下载={}", sync_plan.decision, config.force, remote_should_download ), )); progress(OfficialUpdateProgress::new( "plan", "根据最新种子目录构建官方拉取计划", )); let pull_plan = build_pull_plan( &server_info, &connection_group, &app_version, platforms, &fetcher, &mut progress, &mut should_cancel, )?; let url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len(); progress(OfficialUpdateProgress::new( "plan", format!("拉取计划包含 {url_count} 个官方 URL"), )); let local_state = fetcher .local_resource_state(&pull_plan) .map_err(anyhow::Error::msg)?; let has_local_resources = local_state.has_any_resources(); progress(OfficialUpdateProgress::new( "local-state", format!( "manifest 条目={} 已存在文件={} 是否已有本地资源={}", local_state.manifest_entry_count, local_state.existing_file_count, has_local_resources ), )); let should_audit_local = config.audit_local && has_local_resources; let local_audit = if should_audit_local { progress(OfficialUpdateProgress::new( "audit", format!( "审计本地下载 manifest {}", fetcher.download_manifest_path().display() ), )); Some( fetcher .audit_local_manifest(&pull_plan) .map_err(anyhow::Error::msg)?, ) } else if config.audit_local { progress(OfficialUpdateProgress::new( "audit", "未发现本地资源;首次全量拉取前跳过本地审计", )); None } else { progress(OfficialUpdateProgress::new( "audit", "本地 manifest 审计已关闭", )); None }; let local_manifest_verified_count = local_audit .as_ref() .map(|audit| audit.verified_count()) .unwrap_or(0); let local_manifest_repair_needed_count = local_audit .as_ref() .map(|audit| audit.repair_needed_count()) .unwrap_or(0); let local_manifest_blake3_verified_count = local_audit .as_ref() .map(|audit| audit.manifest_blake3_verified_count()) .unwrap_or(0); let local_zip_structure_verified_count = local_audit .as_ref() .map(|audit| audit.zip_structure_verified_count()) .unwrap_or(0); let repair_needed = config.repair && local_audit .as_ref() .map(|audit| !audit.is_clean()) .unwrap_or(false); let initial_pull_needed = !has_local_resources; let publish_required = !has_current_pointer; let should_download = remote_should_download || repair_needed || initial_pull_needed || publish_required; progress(OfficialUpdateProgress::new( "audit", format!( "本地 manifest 已校验={} 需要修复={}", local_manifest_verified_count, local_manifest_repair_needed_count ), )); progress(OfficialUpdateProgress::new( "decision", format!( "最终决策 需要下载={} 需要修复={} 首次拉取={}", should_download, repair_needed, initial_pull_needed ), )); if publish_required { progress(OfficialUpdateProgress::new( "publish", "尚未存在 current 原子发布指针;本轮会发布 versioned 目录并切换 current", )); } check_shutdown_requested(&mut should_cancel)?; let mut report = OfficialUpdateReport { update_status: if should_download { OfficialUpdateStatus::WouldDownload } else { OfficialUpdateStatus::UpToDate }, connection_group: current_snapshot.connection_group_name.clone(), app_version: current_snapshot.app_version.clone(), bundle_version: current_snapshot.bundle_version.clone(), addressables_root: current_snapshot.addressables_root.clone(), platforms: platforms.to_vec(), 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(), previous_snapshot_present: previous_snapshot.is_some(), decision: format!("{:?}", sync_plan.decision), force: config.force, audit_local: config.audit_local, repair: config.repair, should_download, is_initial: sync_plan.delta.is_initial, changed_endpoint_urls: changed_urls, extended_delta, addressables_marker_checked_count: current_update_snapshot .addressables_marker_checked_count(), unverified_marker_count: current_update_snapshot.unverified_marker_count(), bootstrap_cache_hit: bootstrap.as_ref().map(|bootstrap| bootstrap.cache_hit), bootstrap_cache_path: bootstrap.as_ref().map(|_| bootstrap_cache_path.clone()), local_manifest_verified_count, local_manifest_repair_needed_count, dry_run: config.dry_run, download_url_count: None, download_urls: Vec::new(), resource_count: None, downloaded_count: 0, resumed_count: 0, skipped_count: 0, final_bytes: 0, transferred_bytes: 0, official_seed_hash_verified_count: 0, verification_summary: OfficialVerificationSummary::new( local_manifest_blake3_verified_count, local_manifest_repair_needed_count, 0, local_zip_structure_verified_count, ), download_manifest: fetcher.download_manifest_path(), snapshot_written: None, }; if !should_download { progress(OfficialUpdateProgress::new( "audit", verification_progress_message(&report.verification_summary), )); if !config.dry_run { let current_record = version_record_for_snapshot( ¤t_update_snapshot, VersionRecordInput { id: version_id_from_path(&active_resource_root) .unwrap_or_else(|| fallback_version_id(¤t_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", "资源已是最新;无需下载", )); return Ok(report); } if config.dry_run { if config.plan { let urls = pull_plan.all_urls().map_err(anyhow::Error::msg)?; report.download_url_count = Some(urls.len()); report.download_urls = urls; progress(OfficialUpdateProgress::new( "dry-run", format!( "试运行已生成 {} 个 URL", report.download_url_count.unwrap_or(0) ), )); } else { progress(OfficialUpdateProgress::new( "dry-run", "试运行检测到需要下载;未启用完整 URL 计划输出", )); } report.update_status = OfficialUpdateStatus::WouldDownload; progress(OfficialUpdateProgress::new( "finish", "试运行完成,未写入状态", )); return Ok(report); } check_shutdown_requested(&mut should_cancel)?; let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len(); let publish_plan = publish_layout.plan(¤t_update_snapshot); progress(OfficialUpdateProgress::new( "publish", format!( "准备 staging={} versioned={}", publish_plan.staging_path.display(), publish_plan.version_path.display() ), )); publish_layout .prepare_staging(&publish_plan) .map_err(anyhow::Error::msg)?; 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, ¤t_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, ); report.staging_path = Some(publish_plan.staging_path.clone()); report.snapshot_path = staging_snapshot_path.clone(); report.download_manifest = staging_fetcher.download_manifest_path(); progress(OfficialUpdateProgress::new( "download", format!("下载或复用 {download_url_count} 个官方 URL"), )); let pull_report = staging_fetcher .pull_with_progress_and_cancellation( &pull_plan, |event| { progress(progress_from_pull_event(event)); }, &mut should_cancel, ) .map_err(|error| anyhow::anyhow!(error)) .inspect_err(|error| { let _ = version_state_guard.fail(&error.to_string()); })?; progress(OfficialUpdateProgress::new( "download", format!( "下载阶段完成:已下载={} 已续传={} 已复用={} 本轮传输字节={}", pull_report.downloaded_count(), pull_report.resumed_count(), pull_report.skipped_count(), pull_report.transferred_bytes() ), )); progress(OfficialUpdateProgress::new( "audit", "执行最终本地 manifest 审计", )); check_shutdown_requested(&mut should_cancel)?; let final_audit = staging_fetcher .audit_local_manifest(&pull_plan) .map_err(anyhow::Error::msg)?; let final_verification_summary = OfficialVerificationSummary::new( final_audit.manifest_blake3_verified_count(), final_audit.repair_needed_count(), pull_report.official_hash_verified_count(), final_audit.zip_structure_verified_count(), ); progress(OfficialUpdateProgress::new( "audit", verification_progress_message(&final_verification_summary), )); progress(OfficialUpdateProgress::new( "snapshot", format!("写入快照 {}", staging_snapshot_path.display()), )); if config.snapshot_path.is_none() { write_snapshot(&staging_snapshot_path, ¤t_update_snapshot)?; } progress(OfficialUpdateProgress::new( "publish", format!( "校验完成,发布 versioned 目录并切换 current -> {}", publish_plan.version_path.display() ), )); let published_version_path = publish_layout .publish(&publish_plan) .map_err(anyhow::Error::msg)?; let final_snapshot_path = snapshot_path_for(config, &published_version_path); if config.snapshot_path.is_some() { write_snapshot(&final_snapshot_path, ¤t_update_snapshot)?; } report.update_status = OfficialUpdateStatus::Downloaded; report.active_resource_root = published_version_path.clone(); report.published_version_path = Some(published_version_path.clone()); report.snapshot_path = final_snapshot_path.clone(); report.download_manifest = published_version_path.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE); report.resource_count = Some(pull_report.items.len()); report.downloaded_count = pull_report.downloaded_count(); report.resumed_count = pull_report.resumed_count(); report.skipped_count = pull_report.skipped_count(); report.final_bytes = pull_report.total_bytes(); report.transferred_bytes = pull_report.transferred_bytes(); report.official_seed_hash_verified_count = pull_report.official_hash_verified_count(); 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.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", format!( "同步完成:资源数={} 官方 hash 已校验={}", report.resource_count.unwrap_or(0), report.official_seed_hash_verified_count ), )); Ok(report) } } fn build_pull_plan( server_info: &YostarJpServerInfo, connection_group: &str, app_version: &str, platforms: &[PatchPlatform], fetcher: &OfficialResourcePullService, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result { check_shutdown_requested(should_cancel)?; let discovery = server_info .discovery_plan(connection_group, app_version, platforms) .map_err(anyhow::Error::msg)?; progress(OfficialUpdateProgress::new( "plan", format!( "发现计划已解析:连接组={} 应用版本={} endpoint 数={}", discovery.connection_group_name, discovery.app_version, discovery.endpoints.len() ), )); check_shutdown_requested(should_cancel)?; let seed_catalogs = fetch_seed_catalogs(fetcher, &discovery, progress, should_cancel)?; check_shutdown_requested(should_cancel)?; progress(OfficialUpdateProgress::new( "inventory", "正在把种子目录解析为下载清单", )); let inventory = build_inventory_from_seed_catalogs(&seed_catalogs, platforms)?; Ok(build_official_pull_plan_for_platform_inventory( discovery, inventory, platforms, )) } fn load_server_info( source: &OfficialServerInfoSource, fetcher: &OfficialResourcePullService, ) -> anyhow::Result> { match source { OfficialServerInfoSource::LocalPath(path) => Ok(fs::read(path)?), OfficialServerInfoSource::OfficialFile(file_name) => { let url = server_info_url(file_name).map_err(anyhow::Error::msg)?; fetcher.fetch_bytes(&url).map_err(anyhow::Error::msg) } OfficialServerInfoSource::OfficialUrl(url) => { fetcher.fetch_bytes(url).map_err(anyhow::Error::msg) } } } fn collect_endpoint_markers( fetcher: &OfficialResourcePullService, snapshot: &YostarJpSyncSnapshot, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result> { let mut markers = Vec::new(); for endpoint in &snapshot.endpoints { let Some(role) = marker_role(endpoint.kind) else { continue; }; check_shutdown_requested(should_cancel)?; progress(OfficialUpdateProgress::new( "marker", format!( "拉取 {} 标记{} {}", endpoint_kind_label(endpoint.kind), platform_suffix(endpoint.platform), endpoint.url ), )); let bytes = fetcher .fetch_bytes(&endpoint.url) .map_err(anyhow::Error::msg)?; let value = String::from_utf8_lossy(&bytes).trim().to_string(); markers.push(OfficialEndpointMarkerSnapshot { kind: endpoint.kind, platform: endpoint.platform, url: endpoint.url.clone(), role, value, }); } check_shutdown_requested(should_cancel)?; Ok(markers) } fn check_shutdown_requested(should_cancel: &mut dyn FnMut() -> bool) -> anyhow::Result<()> { if should_cancel() { return Err(anyhow::anyhow!("官方资源更新已被停止请求中断")); } Ok(()) } fn marker_role(kind: YostarJpResourceEndpointKind) -> Option { match kind { YostarJpResourceEndpointKind::TableCatalogHash | YostarJpResourceEndpointKind::BundlePackingInfoHash | YostarJpResourceEndpointKind::MediaCatalogHash => { Some(OfficialEndpointMarkerRole::OfficialSeedHash) } YostarJpResourceEndpointKind::AddressablesCatalogHash => { Some(OfficialEndpointMarkerRole::AddressablesCatalogMarker) } _ => None, } } fn marker_endpoint_count(snapshot: &YostarJpSyncSnapshot) -> usize { snapshot .endpoints .iter() .filter(|endpoint| marker_role(endpoint.kind).is_some()) .count() } fn server_info_source_label(source: &OfficialServerInfoSource) -> String { match source { OfficialServerInfoSource::LocalPath(path) => format!("本地路径 {}", path.display()), OfficialServerInfoSource::OfficialFile(file_name) => { format!("官方文件 {file_name}") } OfficialServerInfoSource::OfficialUrl(url) => format!("官方 URL {url}"), } } fn platforms_label(platforms: &[PatchPlatform]) -> String { platforms .iter() .map(|platform| platform.as_str()) .collect::>() .join(",") } fn endpoint_kind_label(kind: YostarJpResourceEndpointKind) -> &'static str { match kind { YostarJpResourceEndpointKind::TableCatalog => "table_catalog", YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash", YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog", YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash", YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info", YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash", YostarJpResourceEndpointKind::MediaCatalog => "media_catalog", YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash", } } fn platform_suffix(platform: Option) -> String { platform .map(|platform| format!(" ({})", platform.as_str())) .unwrap_or_default() } fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpdateProgress { match event.kind { OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new( "download", format!( "下载进度:总体 {}/{} ({:.1}%);单文件开始 URL={}", event.index, event.total, download_progress_percent(event.index, event.total), event.url ), ) .with_download_progress(&event), OfficialResourcePullProgressKind::Finished => { let status = event.status.map(localized_pull_status).unwrap_or("未知"); OfficialUpdateProgress::new( "download", format!( "下载进度:总体 {}/{} ({:.1}%);单文件完成 状态={} 文件字节={} 本轮传输字节={} URL={}", event.index, event.total, download_progress_percent(event.index, event.total), status, event.bytes.unwrap_or(0), event.transferred_bytes.unwrap_or(0), event.url ), ) .with_download_progress(&event) } OfficialResourcePullProgressKind::Failed => OfficialUpdateProgress::new( "download", format!( "下载中断:总体 {}/{} ({:.1}%);失败类型={} HTTP={} 可重试={} 尝试次数={} quarantine={};本轮跳过该 URL 且不会发布不完整资源 URL={}", event.index, event.total, download_progress_percent(event.index, event.total), event.failure_kind.as_deref().unwrap_or("unknown"), event .failure_http_status .map(|status| status.to_string()) .unwrap_or_else(|| "none".to_string()), event .failure_retryable .map(|retryable| retryable.to_string()) .unwrap_or_else(|| "unknown".to_string()), event .failure_attempts .map(|attempts| attempts.to_string()) .unwrap_or_else(|| "0".to_string()), event.quarantined, event.url ), ) .with_download_progress(&event), } } fn download_progress_percent(index: usize, total: usize) -> f64 { if total == 0 { 100.0 } else { (index as f64 / total as f64) * 100.0 } } fn verification_progress_message(summary: &OfficialVerificationSummary) -> String { format!( "校验结果:官方 .hash={};本地 BLAKE3 通过={};本地需修复={};ZIP 结构通过={}", summary.official_hash_verified_count, summary.local_manifest_blake3_verified_count, summary.local_manifest_repair_needed_count, summary.zip_structure_verified_count ) } 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. pub fn diff_extended_snapshot( current: &OfficialUpdateSnapshot, previous: Option<&OfficialUpdateSnapshot>, ) -> ExtendedSnapshotDelta { let Some(previous) = previous else { return ExtendedSnapshotDelta { endpoint_markers_changed: true, launcher_metadata_changed: current.launcher_metadata.is_some(), game_main_config_changed: current.game_main_config_bootstrap.is_some(), }; }; ExtendedSnapshotDelta { endpoint_markers_changed: current.endpoint_markers != previous.endpoint_markers, launcher_metadata_changed: current.launcher_metadata != previous.launcher_metadata, game_main_config_changed: current.game_main_config_bootstrap != previous.game_main_config_bootstrap, } } fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> { validate_output_root(&config.output_root)?; ensure_safe_directory_path(&config.output_root, "资源输出目录")?; if let Some(snapshot_path) = config.snapshot_path.as_ref() { ensure_path_within_root(&config.output_root, snapshot_path)?; ensure_safe_file_target(&config.output_root, snapshot_path, "官方更新快照")?; } ensure_safe_file_target( &config.output_root, &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(()) } fn snapshot_path_for(config: &OfficialUpdateConfig, resource_root: &Path) -> PathBuf { config .snapshot_path .clone() .unwrap_or_else(|| resource_root.join(OFFICIAL_SYNC_SNAPSHOT_FILE)) } fn path_exists_no_follow(path: &Path) -> Result { match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() => { Err(format!("路径不能是 symlink:{}", path.display())) } Ok(_) => Ok(true), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(error) => Err(format!("检查路径失败 {}:{error}", path.display())), } } fn copy_tree_no_symlink( source: &Path, destination: &Path, skip_publish_management_entries: bool, ) -> Result<(), String> { ensure_safe_directory_path(source, "官方资源发布源目录")?; ensure_safe_directory_path(destination, "官方资源 staging 目录")?; fs::create_dir_all(destination).map_err(|error| { format!( "创建官方资源 staging 子目录失败 {}:{error}", destination.display() ) })?; for entry in fs::read_dir(source) .map_err(|error| format!("读取官方资源发布源目录失败 {}:{error}", source.display()))? { let entry = entry.map_err(|error| { format!("读取官方资源发布源目录项失败 {}:{error}", source.display()) })?; let source_path = entry.path(); let file_name = entry.file_name(); if skip_publish_management_entries { if let Some(name) = file_name.to_str() { if matches!( name, OFFICIAL_STAGING_DIR | OFFICIAL_VERSIONS_DIR | OFFICIAL_CURRENT_LINK | ".official-sync.lock" ) { continue; } } } if destination.starts_with(&source_path) { continue; } let destination_path = destination.join(file_name); let metadata = fs::symlink_metadata(&source_path).map_err(|error| { format!( "读取官方资源发布源元数据失败 {}:{error}", source_path.display() ) })?; if metadata.file_type().is_symlink() { return Err(format!( "官方资源发布源不能包含 symlink:{}", source_path.display() )); } if metadata.is_dir() { copy_tree_no_symlink(&source_path, &destination_path, false)?; } else if metadata.is_file() { if let Some(name) = source_path.file_name().and_then(|value| value.to_str()) { if name.ends_with(".part") || name.ends_with(".tmp") { continue; } } if let Err(_error) = fs::hard_link(&source_path, &destination_path) { fs::copy(&source_path, &destination_path).map_err(|copy_error| { format!( "复制官方资源到 staging 失败 {} -> {}:{copy_error}", source_path.display(), destination_path.display() ) })?; } } else { return Err(format!( "官方资源发布源包含非普通文件:{}", source_path.display() )); } } Ok(()) } #[cfg(unix)] fn switch_current_symlink( root: &Path, current_path: &Path, publish_id: &str, ) -> Result<(), String> { use std::os::unix::fs::symlink; let temporary = root.join(format!(".current.{}.tmp", std::process::id())); if path_exists_no_follow(&temporary)? { fs::remove_file(&temporary).map_err(|error| { format!("清理临时 current 指针失败 {}:{error}", temporary.display()) })?; } let relative_target = Path::new(OFFICIAL_VERSIONS_DIR).join(publish_id); symlink(&relative_target, &temporary).map_err(|error| { format!( "创建临时 current 指针失败 {} -> {}:{error}", temporary.display(), relative_target.display() ) })?; if let Ok(metadata) = fs::symlink_metadata(current_path) { if !metadata.file_type().is_symlink() { let _ = fs::remove_file(&temporary); return Err(format!( "current 已存在但不是 symlink:{}", current_path.display() )); } } fs::rename(&temporary, current_path).map_err(|error| { format!( "切换 current 指针失败 {} -> {}:{error}", temporary.display(), current_path.display() ) }) } #[cfg(not(unix))] fn switch_current_symlink( _root: &Path, _current_path: &Path, _publish_id: &str, ) -> Result<(), String> { Err("原子 current symlink 发布目前只支持 Unix/Linux 平台".to_string()) } fn publish_id(snapshot: &OfficialUpdateSnapshot) -> String { let bundle = snapshot .bundle_version .as_deref() .map(sanitize_publish_segment) .unwrap_or_else(|| "no-bundle".to_string()); format!( "{}-{}-{}-{}-{}", sanitize_publish_segment(&snapshot.app_version), bundle, snapshot.addressables_marker_checked_count(), unix_seconds_now(), std::process::id() ) } fn sanitize_publish_segment(value: &str) -> String { let sanitized = value .chars() .map(|ch| { if ch.is_control() || matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { ch } }) .collect::(); if sanitized.is_empty() { "unknown".to_string() } else { sanitized } } fn unix_seconds_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } /// Reads an official update snapshot from disk, accepting legacy v1 snapshots. pub fn read_snapshot(path: &Path) -> anyhow::Result> { let Some(data) = read_file_no_symlink(path, "官方更新快照").map_err(anyhow::Error::msg)? else { return Ok(None); }; match serde_json::from_slice::(&data) { Ok(snapshot) => Ok(Some(snapshot)), Err(update_error) => { let legacy = serde_json::from_slice::(&data).map_err(|legacy_error| { anyhow::anyhow!( "无法解析官方更新快照:v2 解析失败 ({update_error}),legacy v1 解析也失败 ({legacy_error})" ) })?; Ok(Some(OfficialUpdateSnapshot::new(legacy, Vec::new(), None))) } } } /// Writes an official update snapshot to disk. pub fn write_snapshot(path: &Path, snapshot: &OfficialUpdateSnapshot) -> anyhow::Result<()> { let data = serde_json::to_vec_pretty(snapshot)?; write_file_atomic(path, &data, STATE_FILE_MODE, "官方更新快照").map_err(anyhow::Error::msg)?; Ok(()) } /// Reads the persistent official version state. pub fn read_version_state(path: &Path) -> anyhow::Result> { 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> { let Some(data) = read_file_no_symlink(path, "官方启动缓存").map_err(anyhow::Error::msg)? else { return Ok(None); }; Ok(Some(serde_json::from_slice(&data)?)) } /// Writes an official bootstrap cache to disk. pub fn write_bootstrap_cache(path: &Path, cache: &OfficialBootstrapCache) -> anyhow::Result<()> { let data = serde_json::to_vec_pretty(cache)?; write_file_atomic(path, &data, STATE_FILE_MODE, "官方启动缓存").map_err(anyhow::Error::msg)?; Ok(()) } /// Returns cached GameMainConfig when cache version and metadata match. pub fn cached_game_main_config_for_metadata( cache: &OfficialBootstrapCache, launcher_metadata: &LauncherMetadataSnapshot, ) -> Option { if cache.cache_version == OFFICIAL_BOOTSTRAP_CACHE_VERSION && cache.launcher_metadata == *launcher_metadata { Some(cache.game_main_config.clone()) } else { None } } fn default_update_snapshot_version() -> u32 { OFFICIAL_UPDATE_SNAPSHOT_VERSION } 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, version_path: Option, started_unix_seconds: Option, completed_unix_seconds: Option, } 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(()); }; upsert_failed_version( &mut state.failed_versions, version, "上一次官方资源同步在完成发布前中断", 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 { 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, }, ); remove_matching_version_failures(&mut state.failed_versions, &record); 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 }); if let Some(current) = state.current_completed_version.as_ref() { remove_matching_version_failures(&mut state.failed_versions, current); } 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; upsert_failed_version( &mut state.failed_versions, record, error, 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, } 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) { 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 upsert_failed_version( failed_versions: &mut Vec, record: OfficialVersionRecord, error: &str, failed_unix_seconds: u64, ) { if let Some(index) = failed_versions .iter() .position(|failed| failed_version_matches(&failed.version, &record)) { failed_versions.remove(index); } failed_versions.push(OfficialFailedVersionRecord { version: record, error: error.to_string(), failed_unix_seconds, }); } fn remove_matching_version_failures( failed_versions: &mut Vec, version: &OfficialVersionRecord, ) { failed_versions.retain(|failed| !failed_version_matches(&failed.version, version)); } fn failed_version_matches(left: &OfficialVersionRecord, right: &OfficialVersionRecord) -> bool { left.app_version == right.app_version && left.bundle_version == right.bundle_version && left.addressables_root == right.addressables_root } 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 { 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, manifest_url: &YostarJpLauncherManifestUrl, manifest: &YostarJpLauncherRemoteManifest, ) -> LauncherMetadataSnapshot { LauncherMetadataSnapshot { launcher_version: launcher_version.to_string(), game_latest_version: game_config.game_latest_version.clone(), game_latest_file_path: game_config.game_latest_file_path.clone(), game_lowest_version: game_config.game_lowest_version.clone(), game_start_exe_name: game_config.game_start_exe_name.clone(), game_start_params: game_config.game_start_params.clone(), manifest_url: manifest_url.url.clone(), manifest_source: manifest.source.clone().filter(|value| !value.is_empty()), manifest_file_count: manifest.files.len(), } } fn game_main_config_snapshot(config: &YostarJpGameMainConfig) -> GameMainConfigSnapshot { GameMainConfigSnapshot { server_info_data_url: config.server_info_data_url.clone(), default_connection_group: config.default_connection_group.clone(), } } fn resolve_bootstrap( launcher_version: &str, curl_command: &Path, unzip_command: &Path, cache_path: &Path, write_cache: bool, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result { check_shutdown_requested(should_cancel)?; let launcher = OfficialLauncherBootstrapService::with_curl_command( launcher_version, curl_command.to_string_lossy().to_string(), ); progress(OfficialUpdateProgress::new( "launcher", "正在拉取官方启动器游戏配置和远端 manifest", )); let (game_config, manifest_url, manifest) = launcher .fetch_latest_remote_manifest() .map_err(anyhow::Error::msg)?; check_shutdown_requested(should_cancel)?; let launcher_metadata = launcher_metadata_from_parts(launcher_version, &game_config, &manifest_url, &manifest); progress(OfficialUpdateProgress::new( "launcher", format!( "启动器元数据已解析:最新版本={} manifest 文件数={}", launcher_metadata.game_latest_version, launcher_metadata.manifest_file_count ), )); progress(OfficialUpdateProgress::new( "bootstrap-cache", format!("检查启动缓存 {}", cache_path.display()), )); if let Some(cache) = read_bootstrap_cache(cache_path)? { if let Some(game_main_config) = cached_game_main_config_for_metadata(&cache, &launcher_metadata) { progress(OfficialUpdateProgress::new( "bootstrap-cache", "命中缓存;复用已解析的 GameMainConfig", )); return Ok(ResolvedBootstrap { launcher_metadata, game_main_config, cache_hit: true, }); } } progress(OfficialUpdateProgress::new( "game-main-config", "缓存未命中;拉取 resources.assets 并解析 GameMainConfig", )); check_shutdown_requested(should_cancel)?; let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands( launcher_version, curl_command.to_path_buf(), unzip_command.to_path_buf(), ); let bootstrap = bootstrapper.fetch_bootstrap().map_err(anyhow::Error::msg)?; check_shutdown_requested(should_cancel)?; let launcher_metadata = LauncherMetadataSnapshot { launcher_version: launcher_version.to_string(), game_latest_version: bootstrap.game_config.game_latest_version.clone(), game_latest_file_path: bootstrap.game_config.game_latest_file_path.clone(), game_lowest_version: bootstrap.game_config.game_lowest_version.clone(), game_start_exe_name: bootstrap.game_config.game_start_exe_name.clone(), game_start_params: bootstrap.game_config.game_start_params.clone(), manifest_url: bootstrap.manifest_url.clone(), manifest_source: bootstrap.manifest_source.clone(), manifest_file_count: bootstrap.manifest_file_count, }; let game_main_config = game_main_config_snapshot(&bootstrap.game_main_config); if write_cache { write_bootstrap_cache( cache_path, &OfficialBootstrapCache { cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION, launcher_metadata: launcher_metadata.clone(), game_main_config: game_main_config.clone(), }, )?; progress(OfficialUpdateProgress::new( "bootstrap-cache", format!("启动缓存已写入 {}", cache_path.display()), )); } else { progress(OfficialUpdateProgress::new( "bootstrap-cache", "试运行:不写入启动缓存", )); } Ok(ResolvedBootstrap { launcher_metadata, game_main_config, cache_hit: false, }) } fn fetch_seed_catalogs( fetcher: &OfficialResourcePullService, discovery: &YostarJpResourceDiscoveryPlan, progress: &mut dyn FnMut(OfficialUpdateProgress), should_cancel: &mut dyn FnMut() -> bool, ) -> anyhow::Result { let mut catalogs = SeedCatalogs::default(); for endpoint in &discovery.endpoints { if !matches!( endpoint.kind, YostarJpResourceEndpointKind::TableCatalog | YostarJpResourceEndpointKind::BundlePackingInfo | YostarJpResourceEndpointKind::MediaCatalog ) { continue; } check_shutdown_requested(should_cancel)?; progress(OfficialUpdateProgress::new( "catalog", format!( "拉取种子目录 {}{} {}", endpoint_kind_label(endpoint.kind), platform_suffix(endpoint.platform), endpoint.url ), )); let bytes = fetcher .fetch_bytes(&endpoint.url) .map_err(anyhow::Error::msg)?; catalogs.insert(endpoint, bytes)?; } check_shutdown_requested(should_cancel)?; Ok(catalogs) } fn build_inventory_from_seed_catalogs( catalogs: &SeedCatalogs, platforms: &[PatchPlatform], ) -> anyhow::Result { let table_catalog = catalogs .table_catalog .as_deref() .ok_or_else(|| anyhow::anyhow!("官方发现结果缺少 TableCatalog.bytes"))?; let mut platform_catalogs = Vec::new(); for platform in platforms { let bundle_packing_info = catalogs.bundle_packing_infos.get(platform).ok_or_else(|| { anyhow::anyhow!( "官方发现结果缺少 {} BundlePackingInfo.bytes", platform.as_str() ) })?; let media_catalog = catalogs.media_catalogs.get(platform).ok_or_else(|| { anyhow::anyhow!("官方发现结果缺少 {} MediaCatalog.bytes", platform.as_str()) })?; platform_catalogs.push(YostarJpPlatformCatalogInventory::from_catalog_bytes( *platform, bundle_packing_info, media_catalog, )); } Ok(YostarJpPlatformDownloadInventory::from_catalog_bytes( table_catalog, platform_catalogs, )) } #[derive(Debug, Default)] struct SeedCatalogs { table_catalog: Option>, bundle_packing_infos: HashMap>, media_catalogs: HashMap>, } impl SeedCatalogs { fn insert( &mut self, endpoint: &YostarJpResourceEndpoint, bytes: Vec, ) -> anyhow::Result<()> { match endpoint.kind { YostarJpResourceEndpointKind::TableCatalog => { self.table_catalog = Some(bytes); } YostarJpResourceEndpointKind::BundlePackingInfo => { let platform = required_platform(endpoint)?; self.bundle_packing_infos.insert(platform, bytes); } YostarJpResourceEndpointKind::MediaCatalog => { let platform = required_platform(endpoint)?; self.media_catalogs.insert(platform, bytes); } _ => {} } Ok(()) } } fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result { endpoint.platform.ok_or_else(|| { anyhow::anyhow!( "发现 endpoint {:?} 缺少平台:{}", endpoint.kind, endpoint.url ) }) } #[derive(Debug)] struct OfficialUpdateLock { path: PathBuf, } impl OfficialUpdateLock { fn acquire(config: &OfficialUpdateConfig) -> anyhow::Result { validate_output_root(&config.output_root).map_err(anyhow::Error::msg)?; ensure_safe_directory_path(&config.output_root, "资源输出目录") .map_err(anyhow::Error::msg)?; fs::create_dir_all(&config.output_root)?; ensure_safe_directory_path(&config.output_root, "资源输出目录") .map_err(anyhow::Error::msg)?; let path = config.lock_path(); ensure_safe_file_target(&config.output_root, &path, "官方同步锁") .map_err(anyhow::Error::msg)?; for attempt in 0..=1 { let mut options = OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] options.mode(crate::path_security::PRIVATE_FILE_MODE); match options.open(&path) { Ok(mut file) => { let pid = std::process::id().to_string(); file.write_all(pid.as_bytes())?; return Ok(Self { path }); } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { if attempt == 0 && remove_stale_lock(&path)? { continue; } return Err(anyhow::anyhow!( "官方资源目录已被锁定 (locked):{};{}", path.display(), describe_lock_owner(&path) )); } Err(error) => { return Err(anyhow::anyhow!( "获取官方资源目录状态锁失败 {}:{error}", path.display() )); } } } Err(anyhow::anyhow!( "获取官方资源目录状态锁失败 {}", path.display() )) } } impl Drop for OfficialUpdateLock { fn drop(&mut self) { let expected = std::process::id().to_string(); if fs::symlink_metadata(&self.path) .map(|metadata| metadata.file_type().is_symlink()) .unwrap_or(false) { return; } if fs::read_to_string(&self.path) .map(|contents| contents.trim() == expected) .unwrap_or(false) { let _ = fs::remove_file(&self.path); } } } fn remove_stale_lock(path: &Path) -> anyhow::Result { let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(error) => return Err(error.into()), }; if metadata.file_type().is_symlink() { fs::remove_file(path)?; return Ok(true); } let contents = fs::read_to_string(path).unwrap_or_default(); let Some(pid) = parse_lock_pid(&contents) else { fs::remove_file(path)?; return Ok(true); }; if process_exists(pid) { return Ok(false); } fs::remove_file(path)?; Ok(true) } fn describe_lock_owner(path: &Path) -> String { if fs::symlink_metadata(path) .map(|metadata| metadata.file_type().is_symlink()) .unwrap_or(false) { return "锁文件是 symlink,可执行 clean-stable 清理".to_string(); } let contents = fs::read_to_string(path).unwrap_or_default(); match parse_lock_pid(&contents) { Some(pid) if process_exists(pid) => format!("owner_pid={pid} 仍在运行"), Some(pid) => format!("owner_pid={pid} 已失效,可重新执行或 clean-stable 清理"), None => "锁文件内容不是有效 PID,可执行 clean-stable 清理".to_string(), } } fn parse_lock_pid(contents: &str) -> Option { contents.trim().parse::().ok().filter(|pid| *pid > 0) } #[cfg(unix)] fn process_exists(pid: u32) -> bool { let Ok(pid) = libc::pid_t::try_from(pid) else { return false; }; if pid <= 0 { return false; } unsafe { libc::kill(pid, 0) == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } } #[cfg(not(unix))] fn process_exists(_pid: u32) -> bool { true } #[cfg(test)] mod tests { use super::*; fn fixture_base_snapshot() -> YostarJpSyncSnapshot { YostarJpSyncSnapshot { connection_group_name: "Prod-Audit".to_string(), app_version: "1.70.0".to_string(), bundle_version: Some("bundle".to_string()), addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/root".to_string(), endpoints: vec![ YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::TableCatalog, platform: None, url: "https://prod-clientpatch.bluearchiveyostar.com/root/TableBundles/TableCatalog.bytes".to_string(), }, YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::TableCatalogHash, platform: None, url: "https://prod-clientpatch.bluearchiveyostar.com/root/TableBundles/TableCatalog.hash".to_string(), }, YostarJpResourceEndpoint { kind: YostarJpResourceEndpointKind::AddressablesCatalogHash, platform: Some(PatchPlatform::Windows), url: "https://prod-clientpatch.bluearchiveyostar.com/root/Windows_PatchPack/catalog_StandaloneWindows64.hash".to_string(), }, ], } } fn fixture_marker(value: &str) -> OfficialEndpointMarkerSnapshot { OfficialEndpointMarkerSnapshot { kind: YostarJpResourceEndpointKind::TableCatalogHash, platform: None, url: "https://prod-clientpatch.bluearchiveyostar.com/root/TableBundles/TableCatalog.hash" .to_string(), role: OfficialEndpointMarkerRole::OfficialSeedHash, value: value.to_string(), } } fn fixture_bootstrap() -> ResolvedBootstrap { ResolvedBootstrap { launcher_metadata: LauncherMetadataSnapshot { launcher_version: "1.7.2".to_string(), game_latest_version: "1.70.0".to_string(), game_latest_file_path: "BAJP_1.70.0.zip".to_string(), game_lowest_version: Some("1.69.0".to_string()), game_start_exe_name: Some("BlueArchive".to_string()), game_start_params: vec!["--prod".to_string()], manifest_url: "https://launcher-pkg-ba-jp.yo-star.com/manifest.json".to_string(), manifest_source: Some("BAJP_1.70.0.zip".to_string()), manifest_file_count: 42, }, game_main_config: GameMainConfigSnapshot { server_info_data_url: Some( "https://yostar-serverinfo.bluearchiveyostar.com/prod.json".to_string(), ), default_connection_group: Some("Prod-Audit".to_string()), }, cache_hit: false, } } #[test] fn persists_snapshot_json_round_trip() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("snapshot.json"); let bootstrap = fixture_bootstrap(); let snapshot = OfficialUpdateSnapshot::new( fixture_base_snapshot(), vec![fixture_marker("1234")], Some(&bootstrap), ); write_snapshot(&path, &snapshot).unwrap(); assert_eq!(read_snapshot(&path).unwrap(), Some(snapshot)); } #[test] fn reads_legacy_snapshot_json() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("snapshot.json"); let legacy = fixture_base_snapshot(); fs::write(&path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); let snapshot = read_snapshot(&path).unwrap().unwrap(); assert_eq!(snapshot.base_snapshot(), legacy); assert!(snapshot.endpoint_markers.is_empty()); assert_eq!(snapshot.snapshot_version, OFFICIAL_UPDATE_SNAPSHOT_VERSION); } #[test] fn marker_content_change_requires_download() { let previous = OfficialUpdateSnapshot::new( fixture_base_snapshot(), vec![fixture_marker("1111")], Some(&fixture_bootstrap()), ); let current = OfficialUpdateSnapshot::new( fixture_base_snapshot(), vec![fixture_marker("2222")], Some(&fixture_bootstrap()), ); let delta = diff_extended_snapshot(¤t, Some(&previous)); assert!(delta.endpoint_markers_changed); assert!(delta.has_changes()); assert!(!delta.launcher_metadata_changed); assert!(!delta.game_main_config_changed); } #[test] fn persists_bootstrap_cache_json_round_trip() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("official-bootstrap-cache.json"); let bootstrap = fixture_bootstrap(); let cache = OfficialBootstrapCache { cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION, launcher_metadata: bootstrap.launcher_metadata, game_main_config: bootstrap.game_main_config, }; write_bootstrap_cache(&path, &cache).unwrap(); 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, ¤t_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, ¤t_snapshot, "broken", &published, state .current_completed_version .as_ref() .map(|_| ¤t_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 version_state_deduplicates_repeated_failures_and_clears_after_success() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("official-version-state.json"); let snapshot = OfficialUpdateSnapshot::new( fixture_base_snapshot(), vec![fixture_marker("current")], Some(&fixture_bootstrap()), ); let active = temp.path().join("versions/active"); for id in ["broken-1", "broken-2"] { let staging = temp.path().join(".staging").join(id); let failed = prepare_in_progress_version_state( &path, &snapshot, id, &active, None, &staging, &staging.join("official-sync-snapshot.json"), ) .unwrap(); fail_version_state(&path, failed, "download 403").unwrap(); } let state = read_version_state(&path).unwrap().unwrap(); assert_eq!(state.failed_versions.len(), 1); assert_eq!(state.failed_versions[0].version.id, "broken-2"); assert_eq!(state.failed_versions[0].error, "download 403"); let staging = temp.path().join(".staging/broken-3"); let failed = prepare_in_progress_version_state( &path, &snapshot, "broken-3", &active, None, &staging, &staging.join("official-sync-snapshot.json"), ) .unwrap(); fail_version_state(&path, failed, "download 404").unwrap(); let state = read_version_state(&path).unwrap().unwrap(); assert_eq!(state.failed_versions.len(), 1); assert_eq!(state.failed_versions[0].version.id, "broken-3"); assert_eq!(state.failed_versions[0].error, "download 404"); let staging = temp.path().join(".staging/fixed"); let fixed = prepare_in_progress_version_state( &path, &snapshot, "fixed", &active, None, &staging, &staging.join("official-sync-snapshot.json"), ) .unwrap(); let published = temp.path().join("versions/fixed"); complete_version_state( &path, fixed, &published, &published.join("official-sync-snapshot.json"), ) .unwrap(); let state = read_version_state(&path).unwrap().unwrap(); assert!(state.failed_versions.is_empty()); } #[test] fn update_rejects_dangerous_output_root_before_network_work() { let config = OfficialUpdateConfig { output_root: PathBuf::from("/"), dry_run: true, ..OfficialUpdateConfig::default() }; let error = OfficialUpdateService::new().run(&config).unwrap_err(); assert!(error.to_string().contains("危险路径")); } #[test] fn update_rejects_snapshot_path_escape() { let temp = tempfile::TempDir::new().unwrap(); let config = OfficialUpdateConfig { output_root: temp.path().join("resources"), snapshot_path: Some(temp.path().join("outside-snapshot.json")), dry_run: true, ..OfficialUpdateConfig::default() }; let error = OfficialUpdateService::new().run(&config).unwrap_err(); assert!(error.to_string().contains("路径逃逸")); } #[cfg(unix)] #[test] fn write_snapshot_rejects_symlink_target() { use std::os::unix::fs::symlink; let temp = tempfile::TempDir::new().unwrap(); let outside = temp.path().join("outside.json"); let link = temp.path().join("snapshot.json"); fs::write(&outside, b"outside").unwrap(); symlink(&outside, &link).unwrap(); let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None); let error = write_snapshot(&link, &snapshot).unwrap_err(); assert!(error.to_string().contains("symlink")); assert_eq!(fs::read(&outside).unwrap(), b"outside"); } #[test] fn bootstrap_cache_hit_requires_same_metadata_and_version() { let bootstrap = fixture_bootstrap(); let cache = OfficialBootstrapCache { cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION, launcher_metadata: bootstrap.launcher_metadata.clone(), game_main_config: bootstrap.game_main_config.clone(), }; assert_eq!( cached_game_main_config_for_metadata(&cache, &bootstrap.launcher_metadata), Some(bootstrap.game_main_config.clone()) ); let mut changed_metadata = bootstrap.launcher_metadata.clone(); changed_metadata.game_latest_version = "1.71.0".to_string(); assert_eq!( cached_game_main_config_for_metadata(&cache, &changed_metadata), None ); let stale_version_cache = OfficialBootstrapCache { cache_version: OFFICIAL_BOOTSTRAP_CACHE_VERSION + 1, ..cache }; assert_eq!( cached_game_main_config_for_metadata( &stale_version_cache, &bootstrap.launcher_metadata ), None ); } #[test] fn non_dry_run_lock_rejects_concurrent_writer() { let temp = tempfile::TempDir::new().unwrap(); let config = OfficialUpdateConfig { output_root: temp.path().to_path_buf(), ..OfficialUpdateConfig::default() }; let first = OfficialUpdateLock::acquire(&config).unwrap(); let second = OfficialUpdateLock::acquire(&config).unwrap_err(); assert!(second.to_string().contains("锁定")); drop(first); assert!(OfficialUpdateLock::acquire(&config).is_ok()); } #[test] fn non_dry_run_lock_removes_stale_pid_file() { let temp = tempfile::TempDir::new().unwrap(); let config = OfficialUpdateConfig { output_root: temp.path().to_path_buf(), ..OfficialUpdateConfig::default() }; fs::create_dir_all(&config.output_root).unwrap(); fs::write(config.lock_path(), "999999999").unwrap(); let lock = OfficialUpdateLock::acquire(&config).unwrap(); assert!(config.lock_path().exists()); drop(lock); assert!(!config.lock_path().exists()); } #[test] fn non_dry_run_lock_removes_corrupt_pid_file() { let temp = tempfile::TempDir::new().unwrap(); let config = OfficialUpdateConfig { output_root: temp.path().to_path_buf(), ..OfficialUpdateConfig::default() }; fs::create_dir_all(&config.output_root).unwrap(); fs::write(config.lock_path(), "not-a-pid").unwrap(); let lock = OfficialUpdateLock::acquire(&config).unwrap(); assert_eq!( fs::read_to_string(config.lock_path()).unwrap(), std::process::id().to_string() ); drop(lock); assert!(!config.lock_path().exists()); } #[test] fn update_run_can_be_cancelled_before_network_work() { let temp = tempfile::TempDir::new().unwrap(); let config = OfficialUpdateConfig { output_root: temp.path().to_path_buf(), dry_run: true, ..OfficialUpdateConfig::default() }; let mut checks = 0; let error = OfficialUpdateService::new() .run_with_progress_and_cancellation( &config, |_| {}, || { checks += 1; true }, ) .unwrap_err(); assert!(error.to_string().contains("停止请求")); assert_eq!(checks, 1); } }