1
0
mirror of https://github.com/Bluemangoo/sekai-unpacker.git synced 2026-05-06 20:44:47 +08:00
sekai-unpacker/assets-updater/src/core/asset_execution.rs
2026-04-17 18:56:56 +08:00

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(&region.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")
)
}