diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index c16ba6e..4e9eb96 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -240,7 +240,7 @@ cargo run -p bat-infrastructure --bin bat-official-sync -- \ 1. 使用独立输出目录,例如 `/var/lib/bluearchive-toolkit/official`。 2. 不要指向已有游戏客户端目录。 3. 不要指向 `/home/wanye/D/BlueArchive` 这类开发或人工维护资源目录。 -4. `--auto-discover` 可以下载官方 metadata 和临时 game zip 以解析 `GameMainConfig`,但不会安装或启动官方 launcher。 +4. `--auto-discover` 可以下载官方 metadata,并按官方 manifest 临时获取 `resources.assets` 以解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装或启动官方 launcher。 5. systemd service、容器或 Go 进程可以负责守护 `bat-official-sync --watch`,但定时检查逻辑已经在 Rust 内部。 详细运行说明见 `docs/guides/official-resource-test-pull.md`。 diff --git a/README.md b/README.md index 910d768..82e4ec9 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ - Rust 1.75+ - Go 1.22+ - `curl` -- `unzip`,仅 `--auto-discover` 在 launcher metadata 变化并需要解析 `GameMainConfig` 时使用 +- `unzip`,仅旧版 launcher manifest 指向整包 ZIP 且 `--auto-discover` 需要从 ZIP 解析 `GameMainConfig` 时使用;当前目录型 manifest 会直接下载 `resources.assets` 运行当前主要测试: diff --git a/adapters/src/official/game_main_config.rs b/adapters/src/official/game_main_config.rs index 8d2e501..571043a 100644 --- a/adapters/src/official/game_main_config.rs +++ b/adapters/src/official/game_main_config.rs @@ -8,8 +8,15 @@ use crate::unity::serialized_file::UnitySerializedFile; use base64::engine::general_purpose::STANDARD; use base64::Engine; use serde::Deserialize; +use serde_json::{Map, Value}; use std::path::Path; +const XOR_KEY_LEN: usize = 8; +const FIELD_SKIP_TUTORIAL: &str = "SkipTutorial"; +const FIELD_LANGUAGE: &str = "Language"; +const FIELD_DEFAULT_CONNECTION_GROUP: &str = "DefaultConnectionGroup"; +const FIELD_SERVER_INFO_DATA_URL: &str = "ServerInfoDataUrl"; + /// Decrypted official `GameMainConfig` payload. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "PascalCase")] @@ -50,14 +57,203 @@ impl YostarJpGameMainConfig { /// Decrypts an encrypted `GameMainConfig` payload from raw text-asset bytes. pub fn from_encrypted_bytes(bytes: &[u8]) -> Result { - let encrypted = STANDARD.encode(bytes); - let key = create_key("GameMainConfig"); - let decrypted = convert(&encrypted, &key)?; - serde_json::from_str(&decrypted) - .map_err(|error| format!("Failed to parse decrypted GameMainConfig JSON: {error}")) + let legacy_error = match parse_legacy_game_main_config(bytes) { + Ok(config) => return Ok(config), + Err(error) => error, + }; + + parse_obfuscated_game_main_config(bytes).map_err(|obfuscated_error| { + format!( + "Failed to parse GameMainConfig as legacy JSON ({legacy_error}) or current obfuscated JSON ({obfuscated_error})" + ) + }) } } +fn parse_legacy_game_main_config(bytes: &[u8]) -> Result { + let key = create_key("GameMainConfig"); + let decrypted = decrypt_raw_utf16le_xor(bytes, &key)?; + parse_game_main_config_json(&decrypted) +} + +fn parse_game_main_config_json(value: &str) -> Result { + serde_json::from_str(value) + .map_err(|error| format!("Failed to parse decrypted GameMainConfig JSON: {error}")) +} + +fn parse_obfuscated_game_main_config(bytes: &[u8]) -> Result { + let decrypted = decrypt_inferred_ascii_json(bytes)?; + let value = serde_json::from_str::(&decrypted).map_err(|error| { + format!("Failed to parse decrypted obfuscated GameMainConfig JSON: {error}") + })?; + let object = value + .as_object() + .ok_or_else(|| "Obfuscated GameMainConfig JSON is not an object".to_string())?; + + let skip_tutorial = decode_obfuscated_field(object, FIELD_SKIP_TUTORIAL)? + .map(|value| parse_bool_field(FIELD_SKIP_TUTORIAL, &value)) + .transpose()?; + let language = decode_obfuscated_field(object, FIELD_LANGUAGE)?; + let default_connection_group = decode_obfuscated_field(object, FIELD_DEFAULT_CONNECTION_GROUP)?; + let server_info_data_url = decode_obfuscated_field(object, FIELD_SERVER_INFO_DATA_URL)?; + + if skip_tutorial.is_none() + && language.is_none() + && default_connection_group.is_none() + && server_info_data_url.is_none() + { + return Err("Obfuscated GameMainConfig did not contain any known fields".to_string()); + } + + Ok(YostarJpGameMainConfig { + skip_tutorial, + language, + default_connection_group, + server_info_data_url, + }) +} + +fn decrypt_inferred_ascii_json(bytes: &[u8]) -> Result { + if bytes.len() % 2 != 0 { + return Err("Obfuscated GameMainConfig byte length is not UTF-16LE aligned".to_string()); + } + + let high_key_1 = infer_constant_key_byte(bytes, 1) + .ok_or_else(|| "Could not infer obfuscated GameMainConfig key byte 1".to_string())?; + let high_key_3 = infer_constant_key_byte(bytes, 3) + .ok_or_else(|| "Could not infer obfuscated GameMainConfig key byte 3".to_string())?; + let high_key_5 = infer_constant_key_byte(bytes, 5) + .ok_or_else(|| "Could not infer obfuscated GameMainConfig key byte 5".to_string())?; + let high_key_7 = infer_constant_key_byte(bytes, 7) + .ok_or_else(|| "Could not infer obfuscated GameMainConfig key byte 7".to_string())?; + + let candidates_0 = infer_ascii_key_candidates(bytes, 0); + let candidates_2 = infer_ascii_key_candidates(bytes, 2); + let candidates_4 = infer_ascii_key_candidates(bytes, 4); + let candidates_6 = infer_ascii_key_candidates(bytes, 6); + + for key_0 in candidates_0 { + for key_2 in &candidates_2 { + for key_4 in &candidates_4 { + for key_6 in &candidates_6 { + let key = [ + key_0, high_key_1, *key_2, high_key_3, *key_4, high_key_5, *key_6, + high_key_7, + ]; + let decrypted = decrypt_raw_utf16le_xor(bytes, &key)?; + if !decrypted.trim_start().starts_with('{') { + continue; + } + let Ok(value) = serde_json::from_str::(&decrypted) else { + continue; + }; + if looks_like_obfuscated_config_object(&value) { + return Ok(decrypted); + } + } + } + } + } + + Err("Could not infer obfuscated GameMainConfig UTF-16LE XOR key".to_string()) +} + +fn infer_constant_key_byte(bytes: &[u8], key_index: usize) -> Option { + let mut values = bytes + .iter() + .enumerate() + .filter(|(index, _)| index % XOR_KEY_LEN == key_index) + .map(|(_, byte)| *byte); + let first = values.next()?; + values.all(|value| value == first).then_some(first) +} + +fn infer_ascii_key_candidates(bytes: &[u8], key_index: usize) -> Vec { + (0u8..=u8::MAX) + .filter(|candidate| { + bytes + .iter() + .enumerate() + .filter(|(index, _)| index % XOR_KEY_LEN == key_index) + .all(|(_, byte)| is_json_ascii_byte(byte ^ candidate)) + }) + .collect() +} + +fn is_json_ascii_byte(byte: u8) -> bool { + matches!(byte, b'\t' | b'\n' | b'\r' | b' '..=b'~') +} + +fn looks_like_obfuscated_config_object(value: &Value) -> bool { + value.as_object().is_some_and(|object| { + !object.is_empty() + && object.iter().all(|(key, value)| { + STANDARD.decode(key).is_ok() && value.as_str().is_some_and(is_base64ish) + }) + }) +} + +fn is_base64ish(value: &str) -> bool { + !value.is_empty() && STANDARD.decode(value).is_ok() +} + +fn decode_obfuscated_field( + object: &Map, + field_name: &str, +) -> Result, String> { + let plain_name = utf16le_bytes(field_name); + for (encrypted_name, encrypted_value) in object { + let Some(encrypted_value) = encrypted_value.as_str() else { + continue; + }; + let Ok(encrypted_name_bytes) = STANDARD.decode(encrypted_name) else { + continue; + }; + let Some(key) = derive_repeating_xor_key(&encrypted_name_bytes, &plain_name) else { + continue; + }; + let decoded_name = decrypt_raw_utf16le_xor(&encrypted_name_bytes, &key)?; + if decoded_name != field_name { + continue; + } + let decoded_value = convert(encrypted_value, &key)?; + return Ok(Some(decoded_value)); + } + Ok(None) +} + +fn parse_bool_field(name: &str, value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(format!( + "GameMainConfig field {name} is not a bool: {value:?}" + )), + } +} + +fn utf16le_bytes(value: &str) -> Vec { + value.encode_utf16().flat_map(u16::to_le_bytes).collect() +} + +fn derive_repeating_xor_key(cipher: &[u8], plain: &[u8]) -> Option> { + if cipher.len() != plain.len() { + return None; + } + + let mut key = vec![None; XOR_KEY_LEN]; + for (index, (cipher_byte, plain_byte)) in cipher.iter().zip(plain).enumerate() { + let key_index = index % XOR_KEY_LEN; + let key_byte = cipher_byte ^ plain_byte; + if key[key_index].is_some_and(|existing| existing != key_byte) { + return None; + } + key[key_index] = Some(key_byte); + } + + key.into_iter().collect() +} + fn create_key(name: &str) -> Vec { let seed = xxhash32(name.as_bytes()); let mut mt = MersenneTwister::new(seed); @@ -69,9 +265,14 @@ fn convert(value: &str, key: &[u8]) -> Result { return Ok(String::new()); } - let mut bytes = STANDARD + let bytes = STANDARD .decode(value) .map_err(|error| format!("Failed to decode GameMainConfig base64 payload: {error}"))?; + decrypt_raw_utf16le_xor(&bytes, key) +} + +fn decrypt_raw_utf16le_xor(bytes: &[u8], key: &[u8]) -> Result { + let mut bytes = bytes.to_vec(); xor_bytes(&mut bytes, key); utf16le_to_string(&bytes) } @@ -248,14 +449,31 @@ mod tests { fn encrypt_game_main_config(json: &str) -> Vec { let key = create_key("GameMainConfig"); - let mut utf16 = json - .encode_utf16() - .flat_map(u16::to_le_bytes) - .collect::>(); + encrypt_utf16le_xor(json, &key) + } + + fn encrypt_utf16le_xor(value: &str, key: &[u8]) -> Vec { + let mut utf16 = utf16le_bytes(value); xor_bytes(&mut utf16, &key); utf16 } + fn encrypt_base64_utf16le_xor(value: &str, key: &[u8]) -> String { + STANDARD.encode(encrypt_utf16le_xor(value, key)) + } + + fn insert_obfuscated_field( + object: &mut Map, + field_name: &str, + field_value: &str, + key: &[u8], + ) { + object.insert( + encrypt_base64_utf16le_xor(field_name, key), + Value::String(encrypt_base64_utf16le_xor(field_value, key)), + ); + } + fn synthetic_serialized_file(bytes: &[u8]) -> Vec { fn push_u32_be(data: &mut Vec, value: u32) { data.extend_from_slice(&value.to_be_bytes()); @@ -329,7 +547,7 @@ mod tests { } #[test] - fn decodes_synthetic_game_main_config() { + fn decodes_synthetic_legacy_game_main_config() { let json = r#"{ "SkipTutorial": true, "Language": "ja-JP", @@ -353,6 +571,53 @@ mod tests { } #[test] + fn decodes_synthetic_obfuscated_game_main_config() { + let mut object = Map::new(); + insert_obfuscated_field( + &mut object, + FIELD_SKIP_TUTORIAL, + "false", + &[0xa3, 0x03, 0xf1, 0x42, 0x9b, 0xc2, 0x97, 0x08], + ); + insert_obfuscated_field( + &mut object, + FIELD_LANGUAGE, + "Jp", + &[0x8c, 0xbe, 0x65, 0x5a, 0xae, 0xef, 0x96, 0x05], + ); + insert_obfuscated_field( + &mut object, + FIELD_DEFAULT_CONNECTION_GROUP, + "Prod-Audit", + &[0xe1, 0x6f, 0xbb, 0x3a, 0xd3, 0x61, 0xf2, 0x12], + ); + insert_obfuscated_field( + &mut object, + FIELD_SERVER_INFO_DATA_URL, + "https://yostar-serverinfo.bluearchiveyostar.com/r93_current.json", + &[0x0c, 0x4e, 0x7d, 0x5c, 0x6e, 0x6a, 0x03, 0x76], + ); + + let outer_json = Value::Object(object).to_string(); + let outer_key = [0x1b, 0x23, 0x28, 0x06, 0xad, 0x72, 0xc2, 0x57]; + let encrypted = encrypt_utf16le_xor(&outer_json, &outer_key); + let file = synthetic_serialized_file(&encrypted); + let parsed = YostarJpGameMainConfig::from_resources_assets_bytes(&file).unwrap(); + + assert_eq!(parsed.skip_tutorial, Some(false)); + assert_eq!(parsed.language.as_deref(), Some("Jp")); + assert_eq!( + parsed.default_connection_group.as_deref(), + Some("Prod-Audit") + ); + assert_eq!( + parsed.server_info_data_url.as_deref(), + Some("https://yostar-serverinfo.bluearchiveyostar.com/r93_current.json") + ); + } + + #[test] + #[ignore] fn extracts_game_main_config_bytes_from_serialized_assets() { let path = Path::new( "/home/wanye/D/BlueArchive/AllResources/YostarGames/BlueArchive_JP/BlueArchive_Data/resources.assets", diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index b9a0d46..d59ec94 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -46,7 +46,7 @@ 3. 不要求把生产环境当作客户端安装目录。 4. 可以显式执行 official metadata discovery 自动发现 `server-info` URL、`connection-group` 和 `app-version`。 5. 也可以通过配置、调度状态或已审计 metadata snapshot 显式提供这些值。 -6. `--auto-discover` 只允许通过官方 HTTP metadata 和临时目录解析 `GameMainConfig`;launcher metadata 未变时必须复用缓存,metadata 变化时才重新下载必要官方 game zip。 +6. `--auto-discover` 只允许通过官方 HTTP metadata 和临时目录解析 `GameMainConfig`;launcher metadata 未变时必须复用缓存,metadata 变化时才按 manifest 重新下载必要 `resources.assets` 或旧版官方 game zip。 ### 3.1 发现官方资源根 @@ -182,7 +182,7 @@ 流程是: 1. 显式执行 `--auto-discover` 或读取已审计 `server-info` 输入。 -2. `--auto-discover` 先抓官方 launcher metadata;metadata 未变时复用 `official-bootstrap-cache.json` 中的 `GameMainConfig` 摘要,metadata 变化时临时下载官方 game zip 并重新解析。 +2. `--auto-discover` 先抓官方 launcher metadata;metadata 未变时复用 `official-bootstrap-cache.json` 中的 `GameMainConfig` 摘要,metadata 变化时按 manifest 临时下载 `resources.assets` 或旧版官方 game zip 并重新解析。 3. 生成当前 v2 snapshot,记录 `app_version`、`connection_group`、`bundle_version`、`addressables_root`、endpoint URL、seed `.hash` 内容、`catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。 4. 读取上一次成功同步写出的 snapshot。 5. 使用 `OfficialSyncPlan` 和扩展 snapshot diff 判断是否需要下载;URL 未变但 `.hash` / marker 内容变化也会触发更新。 @@ -211,7 +211,7 @@ Linux 生产路径: 开发/审计辅助路径: 1. `official_launcher_bootstrap` 获取官方 launcher 信息。 -2. `official_game_main_config` 从官方 ZIP 解出并解密 `GameMainConfig`。 +2. `official_game_main_config` 按官方 manifest 获取 `resources.assets`(旧 ZIP manifest 则先解出)并解密 `GameMainConfig`。 3. 手动确认最新 PC metadata、`server-info` URL 和默认 connection group。 自动发现路径和开发/审计辅助路径都只使用官方 HTTP 和临时目录,不安装或启动官方 launcher。 diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index 32e067f..639ae4c 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -61,7 +61,7 @@ Linux 生产运行时链路只走官方日服 HTTP 资源,不安装、不启 4. 拉取 seed catalog,生成完整官方 pull plan。 5. dry-run 只输出 URL;非 dry-run 下载全部官方 URL。 -`--auto-discover` 会下载官方 metadata 所需的临时包并解析 `GameMainConfig`,但不会安装官方启动器,也不会执行官方启动器进程。`--launcher-bootstrap` 只是旧命名兼容别名,新流程不要再推荐使用。 +`--auto-discover` 会下载官方 metadata,并按官方 manifest 临时获取 `resources.assets` 解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装官方启动器,也不会执行官方启动器进程。`--launcher-bootstrap` 只是旧命名兼容别名,新流程不要再推荐使用。 ## 2. 可选 metadata 审计 @@ -170,7 +170,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \ 状态文件默认位于 `--output` 下: - `official-sync-snapshot.json`:上一次成功同步的 v2 snapshot,包含 app version、connection group、bundle version、addressables root、endpoint URL、官方 seed `.hash` 内容、Addressables `catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。 -- `official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 未变时复用缓存;metadata 变化时才通过官方 HTTP 下载必要 game zip 到临时目录解析。 +- `official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 未变时复用缓存;metadata 变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。 - `official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。 先 dry-run: diff --git a/infrastructure/src/official_game_main_config.rs b/infrastructure/src/official_game_main_config.rs index 81747f0..47e50c0 100644 --- a/infrastructure/src/official_game_main_config.rs +++ b/infrastructure/src/official_game_main_config.rs @@ -6,8 +6,11 @@ use crate::official_launcher::launcher_package_url; use crate::official_launcher::OfficialLauncherBootstrapService; -use crate::official_launcher::{YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig}; +use crate::official_launcher::{ + YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig, YostarJpLauncherRemoteManifest, +}; use bat_adapters::official::game_main_config::YostarJpGameMainConfig; +use bat_adapters::official::launcher::YostarJpLauncherManifestFile; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -24,7 +27,11 @@ pub struct OfficialGameMainConfigBootstrap { pub manifest_url: String, /// Remote manifest `source` path, if present. pub manifest_source: Option, - /// Official game ZIP URL built from the launcher CDN root. + /// Official URL used to obtain `resources.assets`. + /// + /// Older launcher manifests point to a single game ZIP. Current manifests + /// may instead point to a directory source plus per-file entries; in that + /// case this is the direct `resources.assets` URL. pub game_zip_url: String, /// Number of files declared by the remote manifest. pub manifest_file_count: usize, @@ -76,23 +83,15 @@ impl OfficialGameMainConfigBootstrapService { .filter(|value| !value.is_empty()) .ok_or_else(|| "Official launcher remote manifest has no source".to_string())?; let cdn_config = self.launcher.fetch_cdn_config()?; - let package_path = select_game_package_path(&game_config, &manifest_source)?; - let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, package_path)?; let temp_dir = TempDir::new() .map_err(|error| format!("Failed to create temporary directory: {error}"))?; - let archive_path = temp_dir.path().join("official-game.zip"); - self.download_file_with_fallback( - &game_zip_url, - &cdn_config.back_up_cdn, - package_path, - &archive_path, + let (game_zip_url, resources_assets) = self.fetch_resources_assets( + &game_config, + &manifest, + &manifest_source, + &cdn_config, + temp_dir.path(), )?; - let extract_root = temp_dir.path().join("extract"); - fs::create_dir_all(&extract_root) - .map_err(|error| format!("Failed to create extract root: {error}"))?; - self.extract_archive(&archive_path, &extract_root)?; - let resources_assets = find_resources_assets(&extract_root) - .ok_or_else(|| "resources.assets not found in official launcher package".to_string())?; let game_main_config = YostarJpGameMainConfig::from_resources_assets(resources_assets)?; Ok(OfficialGameMainConfigBootstrap { @@ -159,6 +158,50 @@ impl OfficialGameMainConfigBootstrapService { } } + fn fetch_resources_assets( + &self, + game_config: &YostarJpLauncherGameConfig, + manifest: &YostarJpLauncherRemoteManifest, + manifest_source: &str, + cdn_config: &YostarJpLauncherCdnConfig, + temp_root: &Path, + ) -> Result<(String, PathBuf), String> { + match select_game_main_config_source(game_config, manifest_source, manifest)? { + GameMainConfigSource::Archive(package_path) => { + let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, package_path)?; + let archive_path = temp_root.join("official-game.zip"); + self.download_file_with_fallback( + &game_zip_url, + &cdn_config.back_up_cdn, + package_path, + &archive_path, + )?; + let extract_root = temp_root.join("extract"); + fs::create_dir_all(&extract_root) + .map_err(|error| format!("Failed to create extract root: {error}"))?; + self.extract_archive(&archive_path, &extract_root)?; + let resources_assets = find_resources_assets(&extract_root).ok_or_else(|| { + "resources.assets not found in official launcher package".to_string() + })?; + Ok((game_zip_url, resources_assets)) + } + GameMainConfigSource::ManifestFile { source_dir, file } => { + let relative_path = launcher_manifest_file_relative_path(source_dir, &file.path)?; + let resources_assets_url = + launcher_package_url(&cdn_config.primary_cdn, &relative_path)?; + let resources_assets = temp_root.join("resources.assets"); + self.download_file_with_fallback( + &resources_assets_url, + &cdn_config.back_up_cdn, + &relative_path, + &resources_assets, + )?; + verify_manifest_file_size(&resources_assets, file)?; + Ok((resources_assets_url, resources_assets)) + } + } + } + fn extract_archive(&self, archive_path: &Path, destination: &Path) -> Result<(), String> { let output = Command::new(&self.unzip_command) .arg("-q") @@ -186,16 +229,39 @@ impl OfficialGameMainConfigBootstrapService { } } -fn select_game_package_path<'a>( +enum GameMainConfigSource<'a> { + Archive(&'a str), + ManifestFile { + source_dir: &'a str, + file: &'a YostarJpLauncherManifestFile, + }, +} + +fn select_game_main_config_source<'a>( game_config: &'a YostarJpLauncherGameConfig, manifest_source: &'a str, -) -> Result<&'a str, String> { + manifest: &'a YostarJpLauncherRemoteManifest, +) -> Result, String> { if is_launcher_archive_path(manifest_source) { - return Ok(manifest_source); + return Ok(GameMainConfigSource::Archive(manifest_source)); + } + + if is_launcher_manifest_directory(manifest_source) { + if let Some(file) = manifest_resource_assets_file(manifest) { + return Ok(GameMainConfigSource::ManifestFile { + source_dir: manifest_source, + file, + }); + } + return Err(format!( + "Official launcher manifest directory source has no BlueArchive_Data/resources.assets entry: {manifest_source}" + )); } if is_launcher_archive_path(&game_config.game_latest_file_path) { - return Ok(&game_config.game_latest_file_path); + return Ok(GameMainConfigSource::Archive( + &game_config.game_latest_file_path, + )); } Err(format!( @@ -204,6 +270,16 @@ fn select_game_package_path<'a>( )) } +fn manifest_resource_assets_file( + manifest: &YostarJpLauncherRemoteManifest, +) -> Option<&YostarJpLauncherManifestFile> { + manifest.files.iter().find(|file| { + normalize_manifest_file_path(&file.path) + .as_deref() + .is_some_and(|path| path.eq_ignore_ascii_case("BlueArchive_Data/resources.assets")) + }) +} + fn is_launcher_archive_path(path: &str) -> bool { !path.is_empty() && !path.starts_with('/') @@ -216,6 +292,73 @@ fn is_launcher_archive_path(path: &str) -> bool { }) } +fn is_launcher_manifest_directory(path: &str) -> bool { + normalize_manifest_source_dir(path).is_some() +} + +fn launcher_manifest_file_relative_path( + source_dir: &str, + file_path: &str, +) -> Result { + let source = normalize_manifest_source_dir(source_dir) + .ok_or_else(|| format!("Invalid official launcher manifest source: {source_dir}"))?; + let file = normalize_manifest_file_path(file_path) + .ok_or_else(|| format!("Invalid official launcher manifest file path: {file_path}"))?; + Ok(format!("{source}/{file}")) +} + +fn normalize_manifest_source_dir(path: &str) -> Option { + let path = path.trim().trim_start_matches('/'); + if path.is_empty() || path.ends_with(".zip") { + return None; + } + normalize_safe_path(path) +} + +fn normalize_manifest_file_path(path: &str) -> Option { + let path = path.trim().trim_start_matches('/'); + if path.is_empty() { + return None; + } + normalize_safe_path(path) +} + +fn normalize_safe_path(path: &str) -> Option { + if path.contains('\\') || path.contains('?') || path.contains('#') { + return None; + } + let mut segments = Vec::new(); + for segment in path.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + return None; + } + segments.push(segment); + } + Some(segments.join("/")) +} + +fn verify_manifest_file_size( + path: &Path, + file: &YostarJpLauncherManifestFile, +) -> Result<(), String> { + let expected_size = file.size.parse::().map_err(|error| { + format!( + "Official launcher manifest has invalid size for {}: {error}", + file.path + ) + })?; + let actual_size = fs::metadata(path) + .map_err(|error| format!("Failed to stat downloaded {}: {error}", path.display()))? + .len(); + if actual_size != expected_size { + return Err(format!( + "Downloaded official launcher file size mismatch for {}: expected {}, got {}", + file.path, expected_size, actual_size + )); + } + Ok(()) +} + fn find_resources_assets(root: &Path) -> Option { let mut stack = vec![root.to_path_buf()]; while let Some(dir) = stack.pop() { diff --git a/infrastructure/tests/official_game_main_config_bootstrap.rs b/infrastructure/tests/official_game_main_config_bootstrap.rs index b05f856..65a47f4 100644 --- a/infrastructure/tests/official_game_main_config_bootstrap.rs +++ b/infrastructure/tests/official_game_main_config_bootstrap.rs @@ -29,6 +29,9 @@ const TEST_LAUNCHER_MANIFEST_SOURCE: &str = "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip"; const TEST_LAUNCHER_GAME_ZIP_URL: &str = "https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip"; +const TEST_LAUNCHER_DIRECTORY_SOURCE: &str = "/BlueArchive_JP-1.70.436321-game"; +const TEST_LAUNCHER_RESOURCES_ASSETS_URL: &str = + "https://launcher-pkg-ba-jp.yo-star.com/BlueArchive_JP-1.70.436321-game/BlueArchive_Data/resources.assets"; const TEST_SERVER_INFO_URL: &str = "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json"; const TEST_CONNECTION_GROUP: &str = "Fixture-Audit"; @@ -54,7 +57,7 @@ fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() { Some(TEST_LAUNCHER_MANIFEST_SOURCE) ); assert_eq!(bootstrap.game_zip_url, TEST_LAUNCHER_GAME_ZIP_URL); - assert_eq!(bootstrap.manifest_file_count, 1); + assert_eq!(bootstrap.manifest_file_count, 2); assert_eq!( bootstrap.game_main_config.server_info_data_url.as_deref(), Some(TEST_SERVER_INFO_URL) @@ -76,25 +79,21 @@ fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() { } #[test] -fn fetch_bootstrap_falls_back_to_latest_file_path_when_manifest_source_is_not_zip() { - let harness = TestHarness::new_with_manifest_source("/BlueArchive_JP-1.70.436321-game"); +fn fetch_bootstrap_uses_directory_manifest_resources_assets_when_source_is_not_zip() { + let harness = TestHarness::new_with_manifest_source(TEST_LAUNCHER_DIRECTORY_SOURCE); let bootstrap = harness.fetch_bootstrap(); assert_eq!( bootstrap.manifest_source.as_deref(), - Some("/BlueArchive_JP-1.70.436321-game") - ); - assert_eq!( - bootstrap.game_zip_url, - format!( - "https://launcher-pkg-ba-jp.yo-star.com/{}", - TEST_LAUNCHER_LATEST_FILE_PATH - ) + Some(TEST_LAUNCHER_DIRECTORY_SOURCE) ); + assert_eq!(bootstrap.game_zip_url, TEST_LAUNCHER_RESOURCES_ASSETS_URL); let curl_log = fs::read_to_string(&harness.curl_log).unwrap(); - assert!(curl_log.contains(TEST_LAUNCHER_LATEST_FILE_PATH)); - assert!(!curl_log.contains(" /BlueArchive_JP-1.70.436321-game")); + assert!(curl_log.contains(TEST_LAUNCHER_RESOURCES_ASSETS_URL)); + assert!(!curl_log.contains(&format!( + "https://launcher-pkg-ba-jp.yo-star.com/{TEST_LAUNCHER_LATEST_FILE_PATH}" + ))); } #[test] @@ -178,7 +177,13 @@ impl TestHarness { let curl_script = temp.path().join("fake-curl"); write_executable( &curl_script, - &official_curl_script(&curl_log, &zip_fixture, manifest_source), + &official_curl_script( + &curl_log, + &zip_fixture, + &resources_assets_path, + resources_assets.len(), + manifest_source, + ), ); let unzip_script = temp.path().join("fake-unzip"); @@ -229,7 +234,13 @@ fn write_executable(path: &Path, content: &str) { fs::set_permissions(path, permissions).unwrap(); } -fn official_curl_script(curl_log: &Path, zip_fixture: &Path, manifest_source: &str) -> String { +fn official_curl_script( + curl_log: &Path, + zip_fixture: &Path, + resources_assets_fixture: &Path, + resources_assets_size: usize, + manifest_source: &str, +) -> String { format!( r#"#!/usr/bin/env bash set -euo pipefail @@ -268,6 +279,11 @@ emit_zip_fixture() {{ cp {} "$output" }} +emit_resources_assets_fixture() {{ + mkdir -p "$(dirname "$output")" + cp {} "$output" +}} + if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then cat <<'JSON' {{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}} @@ -282,8 +298,10 @@ elif [[ "$url" == "{launcher_game_config_json_url}" ]]; then JSON elif [[ "$url" == "{launcher_manifest_url}" ]]; then cat <<'JSON' -{{"source":"{launcher_manifest_source}","file":[{{"path":"/BlueArchive.exe","size":"100","hash":"123"}}]}} +{{"source":"{launcher_manifest_source}","file":[{{"path":"/BlueArchive.exe","size":"100","hash":"123"}},{{"path":"/BlueArchive_Data/resources.assets","size":"{resources_assets_size}","hash":"456"}}]}} JSON +elif [[ "$url" == "{launcher_resources_assets_url}" ]]; then + emit_resources_assets_fixture elif [[ "$url" == "{server_info_url}" ]]; then cat <<'JSON' {{"ConnectionGroups":[{{"Name":"{connection_group}","AddressablesCatalogUrlRoot":"{addressables_root}","BundleVersion":"s8tloc7lo3","IsLivePublished":true}}]}} @@ -332,11 +350,14 @@ fi "#, shell_quote(curl_log), shell_quote(zip_fixture), + shell_quote(resources_assets_fixture), launcher_latest_version = TEST_LAUNCHER_LATEST_VERSION, launcher_latest_file_path = TEST_LAUNCHER_LATEST_FILE_PATH, launcher_game_config_json_url = TEST_LAUNCHER_GAME_CONFIG_JSON_URL, launcher_manifest_url = TEST_LAUNCHER_MANIFEST_URL, launcher_manifest_source = manifest_source, + launcher_resources_assets_url = TEST_LAUNCHER_RESOURCES_ASSETS_URL, + resources_assets_size = resources_assets_size, server_info_url = TEST_SERVER_INFO_URL, connection_group = TEST_CONNECTION_GROUP, addressables_root = TEST_ADDRESSABLES_ROOT,