mirror of
https://github.com/Bluemangoo/sekai-unpacker.git
synced 2026-05-06 20:44:47 +08:00
586 lines
21 KiB
Rust
586 lines
21 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use crate::core::config::{AppConfig, RegionConfig, RegionProviderConfig};
|
|
use crate::core::errors::AssetExecutionError;
|
|
use crate::core::export_pipeline::extract_unity_asset_bundle;
|
|
use crate::core::regions::{compile_patterns, matches_any};
|
|
use crate::core::retry::retry_async;
|
|
use aes::cipher::block_padding::Pkcs7;
|
|
use aes::cipher::{BlockDecryptMut, KeyIvInit};
|
|
use chrono::FixedOffset;
|
|
use common::updater::{AssetCategory, DownloadTask, SyncContext};
|
|
use reqwest::header::{
|
|
ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CONNECTION, COOKIE, HeaderMap, HeaderValue,
|
|
SET_COOKIE, USER_AGENT,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
|
type Aes192CbcDec = cbc::Decryptor<aes::Aes192>;
|
|
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
|
|
|
|
/// Deserializes a msgpack/JSON null or missing value as an empty String.
|
|
/// Go silently coerces nil → zero value for non-pointer types; this helper
|
|
/// mirrors that behavior for String fields.
|
|
fn de_null_as_empty_string<'de, D>(deserializer: D) -> Result<String, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AssetBundleDetail {
|
|
#[serde(rename = "bundleName", deserialize_with = "de_null_as_empty_string")]
|
|
pub bundle_name: String,
|
|
#[serde(rename = "cacheFileName", deserialize_with = "de_null_as_empty_string")]
|
|
pub cache_file_name: String,
|
|
#[serde(
|
|
rename = "cacheDirectoryName",
|
|
deserialize_with = "de_null_as_empty_string"
|
|
)]
|
|
pub cache_directory_name: String,
|
|
// nuverse regions use `crc` instead of `hash`; the server may send nil here.
|
|
#[serde(rename = "hash", deserialize_with = "de_null_as_empty_string")]
|
|
pub hash: String,
|
|
#[serde(rename = "category")]
|
|
pub category: AssetCategory,
|
|
#[serde(rename = "crc")]
|
|
pub crc: i64,
|
|
#[serde(rename = "fileSize")]
|
|
pub file_size: i64,
|
|
#[serde(rename = "dependencies")]
|
|
pub dependencies: Vec<String>,
|
|
#[serde(rename = "paths", default)]
|
|
pub paths: Vec<String>,
|
|
#[serde(rename = "isBuiltin")]
|
|
pub is_builtin: bool,
|
|
#[serde(rename = "isRelocate")]
|
|
pub is_relocate: Option<bool>,
|
|
#[serde(rename = "md5Hash")]
|
|
pub md5_hash: Option<String>,
|
|
#[serde(rename = "downloadPath")]
|
|
pub download_path: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AssetBundleInfo {
|
|
#[serde(rename = "version")]
|
|
pub version: Option<String>,
|
|
#[serde(rename = "os")]
|
|
pub os: Option<String>,
|
|
#[serde(rename = "bundles")]
|
|
pub bundles: HashMap<String, AssetBundleDetail>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AssetExecutionContext {
|
|
client: reqwest::Client,
|
|
region_name: String,
|
|
region: RegionConfig,
|
|
retry: crate::core::config::RetryConfig,
|
|
runtime_cookie: Option<String>,
|
|
resolved_asset_version: Option<String>,
|
|
pub sync_context: SyncContext,
|
|
}
|
|
|
|
impl AssetExecutionContext {
|
|
pub fn new(
|
|
app_config: &AppConfig,
|
|
sync_context: &SyncContext,
|
|
region: &RegionConfig,
|
|
) -> Result<Self, AssetExecutionError> {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
|
|
headers.insert(
|
|
USER_AGENT,
|
|
HeaderValue::from_static("ProductName/134 CFNetwork/1408.0.4 Darwin/22.5.0"),
|
|
);
|
|
headers.insert(CONNECTION, HeaderValue::from_static("keep-alive"));
|
|
headers.insert(
|
|
ACCEPT_ENCODING,
|
|
HeaderValue::from_static("gzip, deflate, br"),
|
|
);
|
|
headers.insert(
|
|
ACCEPT_LANGUAGE,
|
|
HeaderValue::from_static("zh-CN,zh-Hans;q=0.9"),
|
|
);
|
|
headers.insert(
|
|
"X-Unity-Version",
|
|
HeaderValue::from_str(®ion.runtime.unity_version)
|
|
.map_err(|err| AssetExecutionError::HttpClient(err.to_string()))?,
|
|
);
|
|
|
|
let mut builder = reqwest::Client::builder()
|
|
.default_headers(headers)
|
|
.connect_timeout(Duration::from_secs(10))
|
|
.timeout(Duration::from_secs(180))
|
|
.pool_max_idle_per_host(100)
|
|
.tcp_keepalive(Duration::from_secs(30));
|
|
|
|
if let Some(proxy) = &app_config.execution.proxy
|
|
&& !proxy.is_empty()
|
|
{
|
|
builder = builder.proxy(
|
|
reqwest::Proxy::all(proxy)
|
|
.map_err(|err| AssetExecutionError::HttpClient(err.to_string()))?,
|
|
);
|
|
}
|
|
|
|
Ok(Self {
|
|
client: builder
|
|
.build()
|
|
.map_err(|err| AssetExecutionError::HttpClient(err.to_string()))?,
|
|
sync_context: sync_context.clone(),
|
|
region_name: sync_context.region.clone(),
|
|
region: region.clone(),
|
|
retry: app_config.execution.retry.clone(),
|
|
runtime_cookie: None,
|
|
resolved_asset_version: sync_context.asset_version.clone(),
|
|
})
|
|
}
|
|
|
|
pub async fn fetch_tasks(&mut self) -> Result<Vec<DownloadTask>, AssetExecutionError> {
|
|
if self.requires_cookies() {
|
|
self.fetch_runtime_cookies().await?;
|
|
}
|
|
|
|
let info = self.fetch_asset_bundle_info().await?;
|
|
|
|
let tasks = self.build_download_tasks(&info);
|
|
Ok(tasks)
|
|
}
|
|
|
|
pub async fn download(
|
|
&self,
|
|
task: &DownloadTask,
|
|
app_config: &AppConfig,
|
|
) -> Result<(PathBuf, /*single file*/ bool), AssetExecutionError> {
|
|
let ctx = self.clone();
|
|
ctx.download_and_export_bundle(app_config, task).await
|
|
}
|
|
|
|
fn requires_cookies(&self) -> bool {
|
|
match &self.region.provider {
|
|
RegionProviderConfig::ColorfulPalette {
|
|
required_cookies, ..
|
|
} => *required_cookies,
|
|
RegionProviderConfig::Nuverse {
|
|
required_cookies, ..
|
|
} => *required_cookies,
|
|
}
|
|
}
|
|
|
|
async fn fetch_runtime_cookies(&mut self) -> Result<(), AssetExecutionError> {
|
|
let url = match &self.region.provider {
|
|
RegionProviderConfig::ColorfulPalette {
|
|
cookie_bootstrap_url,
|
|
..
|
|
}
|
|
| RegionProviderConfig::Nuverse {
|
|
cookie_bootstrap_url,
|
|
..
|
|
} => cookie_bootstrap_url.clone().unwrap_or_else(|| {
|
|
"https://issue.sekai.colorfulpalette.org/api/signature".to_string()
|
|
}),
|
|
};
|
|
self.runtime_cookie = retry_async(
|
|
&self.retry,
|
|
"cookie bootstrap",
|
|
|_| async {
|
|
let response = self.client.post(&url).send().await?;
|
|
if response.status().is_success() {
|
|
Ok(response
|
|
.headers()
|
|
.get(SET_COOKIE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::to_string))
|
|
} else {
|
|
Err(AssetExecutionError::HttpStatus {
|
|
url: url.clone(),
|
|
status: response.status().as_u16(),
|
|
})
|
|
}
|
|
},
|
|
is_retryable_http_error,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn fetch_asset_bundle_info(&mut self) -> Result<AssetBundleInfo, AssetExecutionError> {
|
|
let url = self.render_asset_info_url().await?;
|
|
let body = self.get_with_retry(&url).await?;
|
|
decrypt_asset_bundle_info(
|
|
self.region.crypto.aes_key_hex.as_deref().ok_or_else(|| {
|
|
AssetExecutionError::MissingCryptoConfig {
|
|
region: self.region_name.clone(),
|
|
}
|
|
})?,
|
|
self.region.crypto.aes_iv_hex.as_deref().ok_or_else(|| {
|
|
AssetExecutionError::MissingCryptoConfig {
|
|
region: self.region_name.clone(),
|
|
}
|
|
})?,
|
|
&body,
|
|
)
|
|
}
|
|
|
|
async fn render_asset_info_url(&mut self) -> Result<String, AssetExecutionError> {
|
|
match &self.region.provider {
|
|
RegionProviderConfig::ColorfulPalette {
|
|
asset_info_url_template,
|
|
profile,
|
|
profile_hashes,
|
|
..
|
|
} => {
|
|
let asset_version =
|
|
self.sync_context.asset_version.as_deref().ok_or_else(|| {
|
|
AssetExecutionError::MissingAssetVersionOrHash {
|
|
region: self.region_name.clone(),
|
|
}
|
|
})?;
|
|
let asset_hash = self.sync_context.asset_hash.as_deref().ok_or_else(|| {
|
|
AssetExecutionError::MissingAssetVersionOrHash {
|
|
region: self.region_name.clone(),
|
|
}
|
|
})?;
|
|
let profile_hash = profile_hashes.get(profile).ok_or_else(|| {
|
|
AssetExecutionError::MissingProfileHash {
|
|
region: self.region_name.clone(),
|
|
profile: profile.clone(),
|
|
}
|
|
})?;
|
|
Ok(asset_info_url_template
|
|
.replace("{env}", profile)
|
|
.replace("{hash}", profile_hash)
|
|
.replace("{asset_version}", asset_version)
|
|
.replace("{asset_hash}", asset_hash)
|
|
+ &time_arg_jst())
|
|
}
|
|
RegionProviderConfig::Nuverse {
|
|
asset_version_url,
|
|
asset_info_url_template,
|
|
..
|
|
} => {
|
|
// For nuverse, always fetch the version from asset_version_url.
|
|
// The incoming request.asset_version is intentionally ignored here
|
|
// to match Go reference behavior.
|
|
let app_version = self.sync_context.app_version.as_ref().ok_or_else(|| {
|
|
AssetExecutionError::MissingAppVersion {
|
|
region: self.region_name.clone(),
|
|
}
|
|
})?;
|
|
let version_url = asset_version_url.replace("{app_version}", app_version);
|
|
let resolved_version =
|
|
String::from_utf8_lossy(&self.get_with_retry(&version_url).await?)
|
|
.trim()
|
|
.to_string();
|
|
self.resolved_asset_version = Some(resolved_version.clone());
|
|
Ok(asset_info_url_template
|
|
.replace("{app_version}", app_version)
|
|
.replace("{asset_version}", &resolved_version)
|
|
+ &time_arg_jst())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_bundle_url(&self, task: &DownloadTask) -> Result<String, AssetExecutionError> {
|
|
match &self.region.provider {
|
|
RegionProviderConfig::ColorfulPalette {
|
|
asset_bundle_url_template,
|
|
profile,
|
|
profile_hashes,
|
|
..
|
|
} => {
|
|
let asset_version =
|
|
self.sync_context.asset_version.as_deref().ok_or_else(|| {
|
|
AssetExecutionError::MissingAssetVersionOrHash {
|
|
region: self.region_name.clone(),
|
|
}
|
|
})?;
|
|
let asset_hash = self.sync_context.asset_hash.as_deref().ok_or_else(|| {
|
|
AssetExecutionError::MissingAssetVersionOrHash {
|
|
region: self.region_name.clone(),
|
|
}
|
|
})?;
|
|
let profile_hash = profile_hashes.get(profile).ok_or_else(|| {
|
|
AssetExecutionError::MissingProfileHash {
|
|
region: self.region_name.clone(),
|
|
profile: profile.clone(),
|
|
}
|
|
})?;
|
|
|
|
Ok(asset_bundle_url_template
|
|
.replace("{bundle_path}", &task.download_path)
|
|
.replace("{asset_version}", asset_version)
|
|
.replace("{asset_hash}", asset_hash)
|
|
.replace("{env}", profile)
|
|
.replace("{hash}", profile_hash)
|
|
+ &time_arg_jst())
|
|
}
|
|
RegionProviderConfig::Nuverse {
|
|
asset_bundle_url_template,
|
|
..
|
|
} => {
|
|
let app_version = self.sync_context.app_version.as_ref().ok_or_else(|| {
|
|
AssetExecutionError::MissingAppVersion {
|
|
region: self.region_name.clone(),
|
|
}
|
|
})?;
|
|
let asset_version = self
|
|
.resolved_asset_version
|
|
.as_deref()
|
|
.unwrap_or("<resolved-asset-version>");
|
|
Ok(asset_bundle_url_template
|
|
.replace("{bundle_path}", &task.download_path)
|
|
.replace("{app_version}", app_version)
|
|
.replace("{asset_version}", asset_version)
|
|
+ &time_arg_jst())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn get_with_retry(&self, url: &str) -> Result<Vec<u8>, AssetExecutionError> {
|
|
retry_async(
|
|
&self.retry,
|
|
"http get",
|
|
|_| async {
|
|
let mut request = self.client.get(url);
|
|
if let Some(cookie) = &self.runtime_cookie {
|
|
request = request.header(COOKIE, cookie);
|
|
}
|
|
match request.send().await {
|
|
Ok(response) if response.status().is_success() => {
|
|
Ok(response.bytes().await?.to_vec())
|
|
}
|
|
Ok(response) => Err(AssetExecutionError::HttpStatus {
|
|
url: url.to_string(),
|
|
status: response.status().as_u16(),
|
|
}),
|
|
Err(err) => Err(AssetExecutionError::Http(err)),
|
|
}
|
|
},
|
|
is_retryable_http_error,
|
|
)
|
|
.await
|
|
}
|
|
|
|
fn build_download_tasks(&self, info: &AssetBundleInfo) -> Vec<DownloadTask> {
|
|
let skip_patterns = compile_patterns(&self.sync_context.filters.skip);
|
|
let start_app_patterns = compile_patterns(&self.sync_context.filters.start_app);
|
|
let on_demand_patterns = compile_patterns(&self.sync_context.filters.on_demand);
|
|
let mut tasks = Vec::new();
|
|
|
|
for (bundle_name, detail) in &info.bundles {
|
|
if matches_any(&skip_patterns, bundle_name) {
|
|
continue;
|
|
}
|
|
let category_patterns = match &detail.category {
|
|
AssetCategory::StartApp => &start_app_patterns,
|
|
AssetCategory::OnDemand => &on_demand_patterns,
|
|
AssetCategory::Other(_) => continue,
|
|
};
|
|
if category_patterns.is_empty() || !matches_any(category_patterns, bundle_name) {
|
|
continue;
|
|
}
|
|
|
|
let bundle_hash = match self.region.provider {
|
|
RegionProviderConfig::Nuverse { .. } => detail.crc.to_string(),
|
|
RegionProviderConfig::ColorfulPalette { .. } => detail.hash.clone(),
|
|
};
|
|
|
|
tasks.push(DownloadTask {
|
|
download_path: download_path_for_region(&self.region.provider, bundle_name, detail),
|
|
bundle_path: bundle_name.clone(),
|
|
bundle_hash,
|
|
category: detail.category.clone(),
|
|
});
|
|
}
|
|
|
|
tasks.sort_by(|a, b| a.bundle_path.cmp(&b.bundle_path));
|
|
tasks
|
|
}
|
|
|
|
async fn download_and_export_bundle(
|
|
&self,
|
|
app_config: &AppConfig,
|
|
task: &DownloadTask,
|
|
) -> Result<(PathBuf, bool), AssetExecutionError> {
|
|
let bundle_url = self.render_bundle_url(task)?;
|
|
let body = self.get_with_retry(&bundle_url).await?;
|
|
let deobfuscated = deobfuscate(&body);
|
|
|
|
let temp_file = std::env::temp_dir()
|
|
.join("sekai-updater")
|
|
.join("obf")
|
|
.join(&self.region_name)
|
|
.join(&task.bundle_path);
|
|
if let Some(parent) = temp_file.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|source| {
|
|
AssetExecutionError::CreateTempDir {
|
|
path: parent.to_path_buf(),
|
|
source,
|
|
}
|
|
})?;
|
|
}
|
|
std::fs::write(&temp_file, deobfuscated).map_err(|source| {
|
|
AssetExecutionError::WriteTempFile {
|
|
path: temp_file.clone(),
|
|
source,
|
|
}
|
|
})?;
|
|
let category = match task.category {
|
|
AssetCategory::StartApp => "StartApp",
|
|
AssetCategory::OnDemand => "OnDemand",
|
|
AssetCategory::Other(_) => "OnDemand",
|
|
};
|
|
let export_result = extract_unity_asset_bundle(
|
|
app_config,
|
|
&self.sync_context,
|
|
&self.region,
|
|
&temp_file,
|
|
&task.bundle_path,
|
|
&task.download_path,
|
|
category,
|
|
)
|
|
.await;
|
|
let _ = std::fs::remove_file(&temp_file);
|
|
export_result.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
pub async fn fetch_live_asset_bundle_info(
|
|
app_config: &AppConfig,
|
|
sync_context: &SyncContext,
|
|
region: &RegionConfig,
|
|
) -> Result<AssetBundleInfo, AssetExecutionError> {
|
|
let mut context = AssetExecutionContext::new(app_config, sync_context, region)?;
|
|
if context.requires_cookies() {
|
|
context.fetch_runtime_cookies().await?;
|
|
}
|
|
context.fetch_asset_bundle_info().await
|
|
}
|
|
|
|
fn is_retryable_http_error(err: &AssetExecutionError) -> bool {
|
|
match err {
|
|
AssetExecutionError::Http(_) => true,
|
|
AssetExecutionError::HttpStatus { status, .. } => *status >= 500,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub fn decrypt_asset_bundle_info(
|
|
aes_key_hex: &str,
|
|
aes_iv_hex: &str,
|
|
content: &[u8],
|
|
) -> Result<AssetBundleInfo, AssetExecutionError> {
|
|
if content.is_empty() {
|
|
return Err(AssetExecutionError::EmptyEncryptedContent);
|
|
}
|
|
if !content.len().is_multiple_of(16) {
|
|
return Err(AssetExecutionError::InvalidEncryptedBlockSize);
|
|
}
|
|
|
|
let key = hex::decode(aes_key_hex)
|
|
.map_err(|err| AssetExecutionError::InvalidAesKeyHex(err.to_string()))?;
|
|
let iv = hex::decode(aes_iv_hex)
|
|
.map_err(|err| AssetExecutionError::InvalidAesIvHex(err.to_string()))?;
|
|
if iv.len() != 16 {
|
|
return Err(AssetExecutionError::InvalidAesIvLength { got: iv.len() });
|
|
}
|
|
|
|
let mut buf = content.to_vec();
|
|
let decrypted = match key.len() {
|
|
16 => Aes128CbcDec::new_from_slices(&key, &iv)
|
|
.map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))?
|
|
.decrypt_padded_mut::<Pkcs7>(&mut buf)
|
|
.map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))?,
|
|
24 => Aes192CbcDec::new_from_slices(&key, &iv)
|
|
.map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))?
|
|
.decrypt_padded_mut::<Pkcs7>(&mut buf)
|
|
.map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))?,
|
|
32 => Aes256CbcDec::new_from_slices(&key, &iv)
|
|
.map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))?
|
|
.decrypt_padded_mut::<Pkcs7>(&mut buf)
|
|
.map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))?,
|
|
_ => {
|
|
return Err(AssetExecutionError::AssetInfoDecode(format!(
|
|
"unsupported AES key length {}",
|
|
key.len()
|
|
)));
|
|
}
|
|
};
|
|
|
|
rmp_serde::from_slice::<AssetBundleInfo>(decrypted)
|
|
.map_err(|err| AssetExecutionError::AssetInfoDecode(err.to_string()))
|
|
}
|
|
|
|
pub fn deobfuscate(data: &[u8]) -> Vec<u8> {
|
|
const SIMPLE: [u8; 4] = [0x20, 0x00, 0x00, 0x00];
|
|
const XOR_HEADER: [u8; 4] = [0x10, 0x00, 0x00, 0x00];
|
|
|
|
if data.starts_with(&SIMPLE) {
|
|
return data[4..].to_vec();
|
|
}
|
|
|
|
if data.starts_with(&XOR_HEADER) {
|
|
let body = &data[4..];
|
|
if body.len() < 128 {
|
|
return body.to_vec();
|
|
}
|
|
|
|
let mut header = vec![0u8; 128];
|
|
let pattern = [0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00];
|
|
for idx in 0..128 {
|
|
header[idx] = body[idx] ^ pattern[idx % pattern.len()];
|
|
}
|
|
let mut output = header;
|
|
output.extend_from_slice(&body[128..]);
|
|
return output;
|
|
}
|
|
|
|
data.to_vec()
|
|
}
|
|
|
|
pub fn should_download_bundle(
|
|
sync_context: &SyncContext,
|
|
bundle_name: &str,
|
|
category: &AssetCategory,
|
|
) -> bool {
|
|
let compiled = match category {
|
|
AssetCategory::StartApp => compile_patterns(&sync_context.filters.start_app),
|
|
AssetCategory::OnDemand => compile_patterns(&sync_context.filters.on_demand),
|
|
AssetCategory::Other(_) => return false,
|
|
};
|
|
if compiled.is_empty() {
|
|
return false;
|
|
}
|
|
matches_any(&compiled, bundle_name)
|
|
}
|
|
|
|
fn download_path_for_region(
|
|
provider: &RegionProviderConfig,
|
|
bundle_name: &str,
|
|
detail: &AssetBundleDetail,
|
|
) -> String {
|
|
match provider {
|
|
RegionProviderConfig::ColorfulPalette { .. } => bundle_name.to_string(),
|
|
RegionProviderConfig::Nuverse { .. } => detail
|
|
.download_path
|
|
.as_ref()
|
|
.map(|prefix| format!("{prefix}/{bundle_name}"))
|
|
.unwrap_or_else(|| bundle_name.to_string()),
|
|
}
|
|
}
|
|
|
|
fn time_arg_jst() -> String {
|
|
let tz = FixedOffset::east_opt(9 * 3600).unwrap();
|
|
format!(
|
|
"?t={}",
|
|
chrono::Utc::now().with_timezone(&tz).format("%Y%m%d%H%M%S")
|
|
)
|
|
}
|