feat(assetbundle): 完善官方资源解析与双目录发布

This commit is contained in:
2026-07-25 20:51:01 +08:00
parent 102b49b666
commit 3e9bb20d79
37 changed files with 4663 additions and 1333 deletions
+950
View File
@@ -0,0 +1,950 @@
//! 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());
}
}