Files
BlueArchiveToolkit/infrastructure/src/official_parse.rs
T

951 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Official resource post-sync parse cache.
//!
//! The parser runs after official download verification has completed. It reads
//! the immutable published resource tree, writes a small derived cache next to
//! `official-download-manifest.json`, and leaves localized output generation to
//! later patch/export stages.
use crate::official_download::{read_download_manifest_at, OfficialDownloadManifestEntry};
use crate::path_security::{
ensure_path_within_root, ensure_safe_file_target, read_file_no_symlink, write_file_atomic,
STATE_FILE_MODE,
};
use bat_assetbundle::{Parser, UnityFsParser};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
/// Parse-cache file name stored under a published official resource root.
pub const OFFICIAL_PARSE_CACHE_FILE: &str = "official-parse-cache.json";
/// Current parse-cache schema version.
pub const OFFICIAL_PARSE_CACHE_VERSION: u32 = 1;
/// Configuration for one official resource parse-cache refresh.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialParseConfig {
/// Published official resource root containing `official-download-manifest.json`.
pub resource_root: PathBuf,
/// `unzip` executable used to inspect zip archives without extracting them.
pub unzip_command: PathBuf,
}
impl OfficialParseConfig {
/// Creates parse-cache configuration for a published official resource root.
pub fn new(resource_root: impl Into<PathBuf>, unzip_command: impl Into<PathBuf>) -> Self {
Self {
resource_root: resource_root.into(),
unzip_command: unzip_command.into(),
}
}
/// Returns the parse-cache path for this resource root.
pub fn cache_path(&self) -> PathBuf {
self.resource_root.join(OFFICIAL_PARSE_CACHE_FILE)
}
}
/// Structured report returned by a parse-cache refresh.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialParseReport {
/// Official resource root that was inspected.
pub resource_root: PathBuf,
/// Parse-cache path written by the refresh.
pub cache_path: PathBuf,
/// Aggregate parse-cache summary.
pub summary: OfficialParseSummary,
}
/// Aggregate counters for a parse-cache refresh.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialParseSummary {
/// Number of entries in `official-download-manifest.json`.
pub manifest_entry_count: usize,
/// Number of entries stored in `official-parse-cache.json`.
pub cache_entry_count: usize,
/// Number of direct bundle files or zip inner files inspected as parser candidates.
pub candidate_file_count: usize,
/// Number of zip inner file entries inspected.
pub zip_entry_count: usize,
/// Number of cached entries reused because source URL/path/size/BLAKE3 did not change.
pub skipped_unchanged_count: usize,
/// Number of bundles parsed successfully.
pub parsed_bundle_count: usize,
/// Number of files intentionally skipped because they are not UnityFS bundles.
pub unsupported_count: usize,
/// Number of files or archives that failed parser/cache inspection.
pub failed_count: usize,
/// Total TextAsset objects found in parsed Unity serialized files.
pub text_asset_count: usize,
}
/// Persistent parse cache for one official resource root.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialParseCache {
/// Cache schema version.
#[serde(default = "default_parse_cache_version")]
pub version: u32,
/// Cache generation time as Unix seconds.
pub generated_unix_seconds: u64,
/// Aggregate summary for this cache.
pub summary: OfficialParseSummary,
/// Cache entries keyed by source URL and optional archive entry.
#[serde(default)]
pub entries: BTreeMap<String, OfficialParseCacheEntry>,
}
/// One parsed or skipped official resource file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialParseCacheEntry {
/// Stable cache key.
pub key: String,
/// Official URL from the download manifest.
pub source_url: String,
/// Relative destination path under the official resource root.
pub destination: String,
/// Inner archive path when the source is a zip file.
pub archive_entry: Option<String>,
/// Source classification used by the parser.
pub source_kind: OfficialParseSourceKind,
/// Fingerprint derived from the download manifest.
pub fingerprint: OfficialParseSourceFingerprint,
/// Parse status for this cache entry.
pub status: OfficialParseStatus,
/// Whether this entry was reused from the previous parse cache in this run.
#[serde(default)]
pub reused_from_previous_cache: bool,
/// Unity editor version when a UnityFS bundle was parsed.
pub unity_version: Option<String>,
/// Number of files extracted from the UnityFS directory table.
pub file_count: usize,
/// Number of Unity serialized files parsed from extracted files.
pub serialized_file_count: usize,
/// Number of TextAsset objects found.
pub text_asset_count: usize,
/// TextAsset names found in stable order.
pub text_assets: Vec<String>,
/// Non-fatal serialized-file parse diagnostic count.
pub serialized_parse_error_count: usize,
/// Human-readable error or skip reason.
pub error: Option<String>,
}
/// Source kind for a parse-cache entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OfficialParseSourceKind {
/// A direct official resource file that should be a UnityFS bundle.
DirectBundle,
/// A file entry streamed from an official zip archive.
ZipEntry,
/// A manifest entry that is not expected to contain a UnityFS bundle.
Unsupported,
}
/// Stable fingerprint copied from the official download manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialParseSourceFingerprint {
/// Official URL from the download manifest.
pub source_url: String,
/// Relative destination path under the official resource root.
pub destination: String,
/// File byte count from the verified download manifest.
pub bytes: u64,
/// BLAKE3 digest from the verified download manifest.
pub blake3: String,
}
/// Parse status for one official resource cache entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OfficialParseStatus {
/// UnityFS parsing succeeded.
Parsed,
/// The source was inspected and is not currently parsed as UnityFS.
SkippedUnsupported,
/// The source was expected to be parseable but failed inspection.
Failed,
}
/// Service that refreshes official parse caches.
#[derive(Debug, Default, Clone, Copy)]
pub struct OfficialParseCacheService;
impl OfficialParseCacheService {
/// Creates a parse-cache service.
pub fn new() -> Self {
Self
}
/// Refreshes the parse cache for the configured official resource root.
pub fn run(&self, config: &OfficialParseConfig) -> Result<OfficialParseReport, String> {
let manifest = read_download_manifest_at(&config.resource_root)?.ok_or_else(|| {
format!(
"缺少官方下载 manifest,无法更新解析缓存:{}",
config.resource_root.display()
)
})?;
let previous_cache = read_parse_cache_at(&config.resource_root)?;
let mut summary = OfficialParseSummary {
manifest_entry_count: manifest.entries.len(),
..OfficialParseSummary::default()
};
let mut entries = BTreeMap::new();
for manifest_entry in manifest.entries.values() {
let produced = process_manifest_entry(config, manifest_entry, previous_cache.as_ref());
for entry in produced {
summary.record_entry(&entry);
entries.insert(entry.key.clone(), entry);
}
}
summary.cache_entry_count = entries.len();
let cache = OfficialParseCache {
version: OFFICIAL_PARSE_CACHE_VERSION,
generated_unix_seconds: unix_seconds_now(),
summary: summary.clone(),
entries,
};
write_parse_cache_at(&config.resource_root, &cache)?;
Ok(OfficialParseReport {
resource_root: config.resource_root.clone(),
cache_path: config.cache_path(),
summary,
})
}
}
impl OfficialParseSummary {
fn record_entry(&mut self, entry: &OfficialParseCacheEntry) {
match entry.source_kind {
OfficialParseSourceKind::DirectBundle => {
self.candidate_file_count += 1;
}
OfficialParseSourceKind::ZipEntry => {
self.candidate_file_count += 1;
self.zip_entry_count += 1;
}
OfficialParseSourceKind::Unsupported => {}
}
match entry.status {
OfficialParseStatus::Parsed => {
self.parsed_bundle_count += 1;
self.text_asset_count += entry.text_asset_count;
}
OfficialParseStatus::SkippedUnsupported => {
self.unsupported_count += 1;
}
OfficialParseStatus::Failed => {
self.failed_count += 1;
}
}
if entry.reused_from_previous_cache {
self.skipped_unchanged_count += 1;
}
}
}
/// Reads the parse cache under a published official resource root.
///
/// Missing, corrupt, or unsupported-version caches return `Ok(None)` so a new
/// cache can be regenerated without blocking official resource publication.
pub fn read_parse_cache_at(resource_root: &Path) -> Result<Option<OfficialParseCache>, String> {
let path = resource_root.join(OFFICIAL_PARSE_CACHE_FILE);
let Some(bytes) = read_file_no_symlink(&path, "官方解析缓存")? else {
return Ok(None);
};
let Ok(cache) = serde_json::from_slice::<OfficialParseCache>(&bytes) else {
return Ok(None);
};
if cache.version != OFFICIAL_PARSE_CACHE_VERSION {
return Ok(None);
}
Ok(Some(cache))
}
/// Writes the parse cache under a published official resource root.
pub fn write_parse_cache_at(
resource_root: &Path,
cache: &OfficialParseCache,
) -> Result<(), String> {
let path = resource_root.join(OFFICIAL_PARSE_CACHE_FILE);
ensure_path_within_root(resource_root, &path)?;
ensure_safe_file_target(resource_root, &path, "官方解析缓存")?;
let bytes = serde_json::to_vec_pretty(cache)
.map_err(|error| format!("序列化官方解析缓存失败:{error}"))?;
write_file_atomic(&path, &bytes, STATE_FILE_MODE, "官方解析缓存")
}
fn process_manifest_entry(
config: &OfficialParseConfig,
manifest_entry: &OfficialDownloadManifestEntry,
previous_cache: Option<&OfficialParseCache>,
) -> Vec<OfficialParseCacheEntry> {
let fingerprint = fingerprint_for(manifest_entry);
if looks_like_zip_source(manifest_entry) {
return process_zip_entry(config, manifest_entry, previous_cache, fingerprint);
}
if looks_like_direct_bundle_source(manifest_entry) {
let key = direct_key(&manifest_entry.url);
if let Some(mut cached) = reusable_entry(previous_cache, &key, &fingerprint) {
cached.reused_from_previous_cache = true;
return vec![cached];
}
return vec![parse_direct_bundle(
config,
manifest_entry,
key,
fingerprint,
)];
}
let key = unsupported_key(&manifest_entry.url);
if let Some(mut cached) = reusable_entry(previous_cache, &key, &fingerprint) {
cached.reused_from_previous_cache = true;
return vec![cached];
}
vec![unsupported_entry(
manifest_entry,
None,
OfficialParseSourceKind::Unsupported,
fingerprint,
key,
"非 UnityFS 候选资源",
)]
}
fn process_zip_entry(
config: &OfficialParseConfig,
manifest_entry: &OfficialDownloadManifestEntry,
previous_cache: Option<&OfficialParseCache>,
fingerprint: OfficialParseSourceFingerprint,
) -> Vec<OfficialParseCacheEntry> {
let cached_entries = reusable_archive_entries(previous_cache, manifest_entry, &fingerprint);
if !cached_entries.is_empty() {
return cached_entries
.into_iter()
.map(|mut entry| {
entry.reused_from_previous_cache = true;
entry
})
.collect();
}
let archive_path = match resource_path_for(&config.resource_root, manifest_entry) {
Ok(path) => path,
Err(error) => {
return vec![failed_entry(
manifest_entry,
None,
OfficialParseSourceKind::ZipEntry,
fingerprint,
zip_list_key(&manifest_entry.url),
error,
)]
}
};
let archive_entries = match list_zip_entries(&config.unzip_command, &archive_path) {
Ok(entries) => entries,
Err(error) => {
return vec![failed_entry(
manifest_entry,
None,
OfficialParseSourceKind::ZipEntry,
fingerprint,
zip_list_key(&manifest_entry.url),
error,
)]
}
};
let mut produced = Vec::new();
for archive_entry in archive_entries {
let key = zip_key(&manifest_entry.url, &archive_entry);
let bytes = match read_zip_entry_bytes(&config.unzip_command, &archive_path, &archive_entry)
{
Ok(bytes) => bytes,
Err(error) => {
produced.push(failed_entry(
manifest_entry,
Some(archive_entry),
OfficialParseSourceKind::ZipEntry,
fingerprint.clone(),
key,
error,
));
continue;
}
};
produced.push(parse_zip_inner_file(
manifest_entry,
archive_entry,
fingerprint.clone(),
key,
&bytes,
));
}
if produced.is_empty() {
produced.push(unsupported_entry(
manifest_entry,
None,
OfficialParseSourceKind::Unsupported,
fingerprint,
zip_list_key(&manifest_entry.url),
"ZIP 内没有可检查文件条目",
));
}
produced
}
fn parse_direct_bundle(
config: &OfficialParseConfig,
manifest_entry: &OfficialDownloadManifestEntry,
key: String,
fingerprint: OfficialParseSourceFingerprint,
) -> OfficialParseCacheEntry {
let path = match resource_path_for(&config.resource_root, manifest_entry) {
Ok(path) => path,
Err(error) => {
return failed_entry(
manifest_entry,
None,
OfficialParseSourceKind::DirectBundle,
fingerprint,
key,
error,
)
}
};
let bytes = match read_resource_file(&path) {
Ok(bytes) => bytes,
Err(error) => {
return failed_entry(
manifest_entry,
None,
OfficialParseSourceKind::DirectBundle,
fingerprint,
key,
error,
)
}
};
if !UnityFsParser::has_unityfs_signature(&bytes) {
return unsupported_entry(
manifest_entry,
None,
OfficialParseSourceKind::DirectBundle,
fingerprint,
key,
"文件不是 UnityFS bundle",
);
}
parsed_bundle_entry(
manifest_entry,
None,
OfficialParseSourceKind::DirectBundle,
fingerprint,
key,
&bytes,
)
}
fn parse_zip_inner_file(
manifest_entry: &OfficialDownloadManifestEntry,
archive_entry: String,
fingerprint: OfficialParseSourceFingerprint,
key: String,
bytes: &[u8],
) -> OfficialParseCacheEntry {
if !UnityFsParser::has_unityfs_signature(bytes) {
return unsupported_entry(
manifest_entry,
Some(archive_entry),
OfficialParseSourceKind::ZipEntry,
fingerprint,
key,
"ZIP 条目不是 UnityFS bundle",
);
}
parsed_bundle_entry(
manifest_entry,
Some(archive_entry),
OfficialParseSourceKind::ZipEntry,
fingerprint,
key,
bytes,
)
}
fn parsed_bundle_entry(
manifest_entry: &OfficialDownloadManifestEntry,
archive_entry: Option<String>,
source_kind: OfficialParseSourceKind,
fingerprint: OfficialParseSourceFingerprint,
key: String,
bytes: &[u8],
) -> OfficialParseCacheEntry {
let parser = UnityFsParser::new();
match parser.parse(bytes) {
Ok(parsed) => OfficialParseCacheEntry {
key,
source_url: manifest_entry.url.clone(),
destination: manifest_entry.destination.clone(),
archive_entry,
source_kind,
fingerprint,
status: OfficialParseStatus::Parsed,
reused_from_previous_cache: false,
unity_version: Some(parsed.unity_version),
file_count: parsed.files.len(),
serialized_file_count: parsed.serialized_files.len(),
text_asset_count: parsed.text_assets.len(),
text_assets: parsed
.text_assets
.iter()
.map(|asset| asset.name.clone())
.collect(),
serialized_parse_error_count: parsed.serialized_parse_errors.len(),
error: None,
},
Err(error) => failed_entry(
manifest_entry,
archive_entry,
source_kind,
fingerprint,
key,
error.to_string(),
),
}
}
fn failed_entry(
manifest_entry: &OfficialDownloadManifestEntry,
archive_entry: Option<String>,
source_kind: OfficialParseSourceKind,
fingerprint: OfficialParseSourceFingerprint,
key: String,
error: impl Into<String>,
) -> OfficialParseCacheEntry {
status_entry(
manifest_entry,
archive_entry,
source_kind,
fingerprint,
key,
OfficialParseStatus::Failed,
error.into(),
)
}
fn unsupported_entry(
manifest_entry: &OfficialDownloadManifestEntry,
archive_entry: Option<String>,
source_kind: OfficialParseSourceKind,
fingerprint: OfficialParseSourceFingerprint,
key: String,
reason: impl Into<String>,
) -> OfficialParseCacheEntry {
status_entry(
manifest_entry,
archive_entry,
source_kind,
fingerprint,
key,
OfficialParseStatus::SkippedUnsupported,
reason.into(),
)
}
fn status_entry(
manifest_entry: &OfficialDownloadManifestEntry,
archive_entry: Option<String>,
source_kind: OfficialParseSourceKind,
fingerprint: OfficialParseSourceFingerprint,
key: String,
status: OfficialParseStatus,
reason: String,
) -> OfficialParseCacheEntry {
OfficialParseCacheEntry {
key,
source_url: manifest_entry.url.clone(),
destination: manifest_entry.destination.clone(),
archive_entry,
source_kind,
fingerprint,
status,
reused_from_previous_cache: false,
unity_version: None,
file_count: 0,
serialized_file_count: 0,
text_asset_count: 0,
text_assets: Vec::new(),
serialized_parse_error_count: 0,
error: Some(reason),
}
}
fn read_resource_file(path: &Path) -> Result<Vec<u8>, String> {
read_file_no_symlink(path, "官方解析输入")?
.ok_or_else(|| format!("官方解析输入不存在:{}", path.display()))
}
fn resource_path_for(
resource_root: &Path,
manifest_entry: &OfficialDownloadManifestEntry,
) -> Result<PathBuf, String> {
let path = resource_root.join(Path::new(&manifest_entry.destination));
ensure_path_within_root(resource_root, &path)?;
ensure_safe_file_target(resource_root, &path, "官方解析输入")?;
Ok(path)
}
fn list_zip_entries(unzip_command: &Path, archive_path: &Path) -> Result<Vec<String>, String> {
let output = Command::new(unzip_command)
.arg("-Z1")
.arg(archive_path)
.output()
.map_err(|error| {
format!(
"启动 unzip 列出 ZIP 条目失败 {}{error}",
archive_path.display()
)
})?;
if !output.status.success() {
return Err(format!(
"列出 ZIP 条目失败 {}{}",
archive_path.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| line.trim_end_matches('\r').trim().to_string())
.filter(|line| !line.is_empty() && !line.ends_with('/'))
.collect())
}
fn read_zip_entry_bytes(
unzip_command: &Path,
archive_path: &Path,
archive_entry: &str,
) -> Result<Vec<u8>, String> {
let output = Command::new(unzip_command)
.arg("-p")
.arg(archive_path)
.arg(archive_entry)
.output()
.map_err(|error| {
format!(
"启动 unzip 读取 ZIP 条目失败 {}!{}{error}",
archive_path.display(),
archive_entry
)
})?;
if !output.status.success() {
return Err(format!(
"读取 ZIP 条目失败 {}!{}{}",
archive_path.display(),
archive_entry,
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(output.stdout)
}
fn reusable_entry(
previous_cache: Option<&OfficialParseCache>,
key: &str,
fingerprint: &OfficialParseSourceFingerprint,
) -> Option<OfficialParseCacheEntry> {
previous_cache
.and_then(|cache| cache.entries.get(key))
.filter(|entry| &entry.fingerprint == fingerprint)
.cloned()
}
fn reusable_archive_entries(
previous_cache: Option<&OfficialParseCache>,
manifest_entry: &OfficialDownloadManifestEntry,
fingerprint: &OfficialParseSourceFingerprint,
) -> Vec<OfficialParseCacheEntry> {
previous_cache
.into_iter()
.flat_map(|cache| cache.entries.values())
.filter(|entry| {
entry.source_url == manifest_entry.url
&& entry.destination == manifest_entry.destination
&& &entry.fingerprint == fingerprint
})
.cloned()
.collect()
}
fn fingerprint_for(
manifest_entry: &OfficialDownloadManifestEntry,
) -> OfficialParseSourceFingerprint {
OfficialParseSourceFingerprint {
source_url: manifest_entry.url.clone(),
destination: manifest_entry.destination.clone(),
bytes: manifest_entry.bytes,
blake3: manifest_entry.blake3.clone(),
}
}
fn looks_like_zip_source(manifest_entry: &OfficialDownloadManifestEntry) -> bool {
has_case_insensitive_suffix(&manifest_entry.url, ".zip")
|| has_case_insensitive_suffix(&manifest_entry.destination, ".zip")
}
fn looks_like_direct_bundle_source(manifest_entry: &OfficialDownloadManifestEntry) -> bool {
[".bundle", ".unity3d"].iter().any(|suffix| {
has_case_insensitive_suffix(&manifest_entry.url, suffix)
|| has_case_insensitive_suffix(&manifest_entry.destination, suffix)
})
}
fn has_case_insensitive_suffix(value: &str, suffix: &str) -> bool {
value
.rsplit(['/', '\\'])
.next()
.is_some_and(|name| name.to_ascii_lowercase().ends_with(suffix))
}
fn direct_key(url: &str) -> String {
format!("direct:{url}")
}
fn zip_key(url: &str, archive_entry: &str) -> String {
format!("zip:{url}!{archive_entry}")
}
fn zip_list_key(url: &str) -> String {
format!("zip-list:{url}")
}
fn unsupported_key(url: &str) -> String {
format!("unsupported:{url}")
}
fn default_parse_cache_version() -> u32 {
OFFICIAL_PARSE_CACHE_VERSION
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn push_c_string(data: &mut Vec<u8>, value: &str) {
data.extend_from_slice(value.as_bytes());
data.push(0);
}
fn push_u16(data: &mut Vec<u8>, value: u16) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_i32(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn synthetic_unityfs_bundle(directory_path: &str, payload: &[u8]) -> Vec<u8> {
let mut blocks_info = Vec::new();
blocks_info.extend_from_slice(&[0xAB; 16]);
push_i32(&mut blocks_info, 1);
push_u32(&mut blocks_info, payload.len() as u32);
push_u32(&mut blocks_info, payload.len() as u32);
push_u16(&mut blocks_info, 0);
push_i32(&mut blocks_info, 1);
push_u64(&mut blocks_info, 0);
push_u64(&mut blocks_info, payload.len() as u64);
push_u32(&mut blocks_info, 0);
push_c_string(&mut blocks_info, directory_path);
let mut data = Vec::new();
push_c_string(&mut data, "UnityFS");
push_u32(&mut data, 8);
push_c_string(&mut data, "5.x.x");
push_c_string(&mut data, "2021.3.56f2");
push_u64(&mut data, 0);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, 0);
align(&mut data, 16);
data.extend_from_slice(&blocks_info);
data.extend_from_slice(payload);
let total_size = data.len() as u64;
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
data
}
fn write_download_manifest(
root: &Path,
entries: &[(&str, &str, &[u8])],
) -> Vec<OfficialDownloadManifestEntry> {
let mut manifest_entries = BTreeMap::new();
let mut written = Vec::new();
for (url, destination, bytes) in entries {
let path = root.join(destination);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, bytes).unwrap();
let entry = OfficialDownloadManifestEntry {
url: (*url).to_string(),
destination: (*destination).to_string(),
bytes: bytes.len() as u64,
blake3: blake3::hash(bytes).to_hex().to_string(),
};
manifest_entries.insert((*url).to_string(), entry.clone());
written.push(entry);
}
let manifest = serde_json::json!({
"version": 1,
"entries": manifest_entries,
});
fs::write(
root.join("official-download-manifest.json"),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
written
}
#[test]
fn parses_direct_bundle_and_reuses_unchanged_cache() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path();
let bundle = synthetic_unityfs_bundle("CAB-fixture", b"data");
write_download_manifest(
root,
&[(
"https://prod-clientpatch.bluearchiveyostar.com/r93/Bundle/test.bundle",
"Bundle/test.bundle",
&bundle,
)],
);
let config = OfficialParseConfig::new(root, "unzip");
let first = OfficialParseCacheService::new().run(&config).unwrap();
assert_eq!(first.summary.manifest_entry_count, 1);
assert_eq!(first.summary.parsed_bundle_count, 1);
assert_eq!(first.summary.skipped_unchanged_count, 0);
assert!(first.cache_path.exists());
let second = OfficialParseCacheService::new().run(&config).unwrap();
assert_eq!(second.summary.parsed_bundle_count, 1);
assert_eq!(second.summary.skipped_unchanged_count, 1);
let cache = read_parse_cache_at(root).unwrap().unwrap();
let entry = cache.entries.values().next().unwrap();
assert_eq!(entry.status, OfficialParseStatus::Parsed);
assert_eq!(entry.file_count, 1);
}
#[test]
fn records_non_candidate_resources_as_unsupported() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path();
write_download_manifest(
root,
&[(
"https://prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/TableCatalog.bytes",
"TableBundles/TableCatalog.bytes",
b"catalog",
)],
);
let config = OfficialParseConfig::new(root, "unzip");
let report = OfficialParseCacheService::new().run(&config).unwrap();
assert_eq!(report.summary.parsed_bundle_count, 0);
assert_eq!(report.summary.unsupported_count, 1);
assert_eq!(report.summary.failed_count, 0);
}
#[cfg(unix)]
#[test]
fn parses_zip_entries_without_extracting_archive_tree() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path();
let bundle = synthetic_unityfs_bundle("CAB-zipped", b"zipdata");
let bundle_path = temp.path().join("inner.bundle");
fs::write(&bundle_path, &bundle).unwrap();
let archive_bytes = b"zip-placeholder";
write_download_manifest(
root,
&[(
"https://prod-clientpatch.bluearchiveyostar.com/r93/Windows_PatchPack/FullPatch_000.zip",
"Windows_PatchPack/FullPatch_000.zip",
archive_bytes,
)],
);
let unzip_script = temp.path().join("fake-unzip");
fs::write(
&unzip_script,
format!(
r#"#!/usr/bin/env bash
set -euo pipefail
if [[ "${{1:-}}" == "-Z1" ]]; then
printf '%s\n' 'assets/scenario.bundle' 'assets/readme.txt'
exit 0
fi
if [[ "${{1:-}}" == "-p" && "${{3:-}}" == "assets/scenario.bundle" ]]; then
cat '{}'
exit 0
fi
if [[ "${{1:-}}" == "-p" ]]; then
printf 'plain text'
exit 0
fi
exit 2
"#,
bundle_path.display()
),
)
.unwrap();
let mut permissions = fs::metadata(&unzip_script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&unzip_script, permissions).unwrap();
let config = OfficialParseConfig::new(root, unzip_script);
let report = OfficialParseCacheService::new().run(&config).unwrap();
assert_eq!(report.summary.manifest_entry_count, 1);
assert_eq!(report.summary.zip_entry_count, 2);
assert_eq!(report.summary.parsed_bundle_count, 1);
assert_eq!(report.summary.unsupported_count, 1);
assert_eq!(report.summary.failed_count, 0);
assert!(!root.join("assets").exists());
}
}