feat: prepare experiment push package

This commit is contained in:
2026-06-30 00:08:36 +08:00
parent 3c00659691
commit adc1cd5b91
45 changed files with 7858 additions and 65 deletions
+369
View File
@@ -0,0 +1,369 @@
//! 资源导入服务。
use bat_adapters::manifest::GenericManifest;
use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry};
use bat_core::domain::{Resource, ResourceType};
use bat_core::repositories::{CasRepository, ResourceRepository};
use std::path::{Path, PathBuf};
/// 待导入的 AssetBundle 数据。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BundleSource {
/// Bundle 路径或文件名。
pub path: String,
/// Bundle 原始字节。
pub data: Vec<u8>,
}
impl BundleSource {
/// 创建 Bundle 输入。
pub fn new(path: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
Self {
path: path.into(),
data: data.into(),
}
}
}
/// 单个导入结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedResource {
/// 写入 `ResourceRepository` 的资源 ID。
pub id: String,
/// Manifest 中的资源路径。
pub source_path: String,
/// CAS 返回的对象 ID。
pub object_id: String,
/// 写入 CAS 的字节数。
pub bytes: u64,
/// UnityFS 解析摘要。
pub unityfs: UnityFsImportSummary,
}
/// 导入时解析到的 UnityFS 基础结构摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnityFsImportSummary {
/// Bundle 声明的 Unity 版本。
pub unity_version: String,
/// UnityFS block 数量。
pub block_count: usize,
/// UnityFS directory 数量。
pub directory_count: usize,
/// UnityFS directory 路径。
pub directories: Vec<String>,
}
/// Manifest 导入报告。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourceImportReport {
/// 成功导入的资源。
pub imported: Vec<ImportedResource>,
/// 被跳过的非 AssetBundle 资源路径。
pub skipped: Vec<String>,
}
/// 将解析后的资源清单写入 CAS 与资源仓储的应用服务。
pub struct ResourceImportService<'a> {
cas: &'a dyn CasRepository,
resources: &'a dyn ResourceRepository,
unity_adapters: UnityAdapterRegistry,
}
impl<'a> ResourceImportService<'a> {
/// 创建导入服务。
pub fn new(cas: &'a dyn CasRepository, resources: &'a dyn ResourceRepository) -> Self {
Self::with_unity_adapters(cas, resources, UnityAdapterRegistry::with_defaults())
}
/// 使用指定 Unity 适配器注册表创建导入服务。
pub fn with_unity_adapters(
cas: &'a dyn CasRepository,
resources: &'a dyn ResourceRepository,
unity_adapters: UnityAdapterRegistry,
) -> Self {
Self {
cas,
resources,
unity_adapters,
}
}
/// 导入 manifest 中的 AssetBundle 资源。
///
/// 非 AssetBundle 条目会记录到 `skipped`。AssetBundle 条目必须能在
/// `bundles` 中按完整路径或文件名找到对应数据,否则返回错误。
pub async fn import_manifest_bundles(
&self,
manifest: &GenericManifest,
bundles: &[BundleSource],
) -> bat_core::Result<ResourceImportReport> {
let mut report = ResourceImportReport::default();
for entry in &manifest.resources {
if entry.resource_type != ResourceType::AssetBundle {
report.skipped.push(entry.path.clone());
continue;
}
let bundle = find_bundle(bundles, &entry.path).ok_or_else(|| {
bat_core::Error::InvalidArgument(format!(
"Missing bundle data for manifest resource: {}",
entry.path
))
})?;
let unityfs = self.parse_unityfs_summary(&entry.path, bundle).await?;
let object_id = self.cas.store(&bundle.data).await?;
let mut stored_entry = entry.clone();
stored_entry.hash = object_id.clone();
stored_entry.size = bundle.data.len() as u64;
let resource = Resource {
id: resource_id_for_path(&entry.path),
local_path: PathBuf::from(&entry.path),
entry: stored_entry,
};
let id = self.resources.add(resource).await?;
report.imported.push(ImportedResource {
id,
source_path: entry.path.clone(),
object_id,
bytes: bundle.data.len() as u64,
unityfs,
});
}
Ok(report)
}
async fn parse_unityfs_summary(
&self,
manifest_path: &str,
bundle: &BundleSource,
) -> bat_core::Result<UnityFsImportSummary> {
let raw_bundle = RawAssetBundle {
data: bundle.data.clone(),
path: Some(manifest_path.to_string()),
};
let adapter = self
.unity_adapters
.select_adapter(&raw_bundle)
.map_err(|error| {
bat_core::Error::InvalidArgument(format!(
"No Unity adapter for manifest resource {}: {}",
manifest_path, error
))
})?;
let parsed = adapter.parse(&raw_bundle).await.map_err(|error| {
bat_core::Error::InvalidArgument(format!(
"Invalid UnityFS bundle for manifest resource {}: {}",
manifest_path, error
))
})?;
Ok(UnityFsImportSummary {
unity_version: parsed.unity_version,
block_count: parsed.blocks.len(),
directory_count: parsed.directories.len(),
directories: parsed
.directories
.into_iter()
.map(|directory| directory.path)
.collect(),
})
}
}
fn resource_id_for_path(path: &str) -> String {
format!("resource/{}", path)
}
fn find_bundle<'a>(bundles: &'a [BundleSource], manifest_path: &str) -> Option<&'a BundleSource> {
bundles
.iter()
.find(|bundle| bundle.path == manifest_path)
.or_else(|| {
file_name(manifest_path).and_then(|manifest_name| {
bundles
.iter()
.find(|bundle| file_name(&bundle.path) == Some(manifest_name))
})
})
}
fn file_name(path: &str) -> Option<&str> {
Path::new(path).file_name()?.to_str()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FileSystemCasRepository, InMemoryResourceRepository};
use bat_adapters::manifest::{GenericManifest, ManifestFormat, ManifestMetadata};
use bat_core::domain::ResourceEntry;
use bat_core::repositories::cas_repository::CasRepository;
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
use std::collections::HashMap;
use tempfile::TempDir;
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_minimal_unityfs_bundle() -> Vec<u8> {
let mut blocks_info = Vec::new();
blocks_info.extend_from_slice(&[0; 16]);
push_i32(&mut blocks_info, 1);
push_u32(&mut blocks_info, 4);
push_u32(&mut blocks_info, 4);
push_u16(&mut blocks_info, 0);
push_i32(&mut blocks_info, 1);
push_u64(&mut blocks_info, 0);
push_u64(&mut blocks_info, 4);
push_u32(&mut blocks_info, 0);
push_c_string(&mut blocks_info, "SYNTHETIC-CAB");
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(b"data");
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 synthetic_manifest() -> GenericManifest {
GenericManifest {
format: ManifestFormat::AddressablesCatalog,
resources: vec![
ResourceEntry {
path: "synthetic/minimal.bundle".to_string(),
hash: "synthetic-manifest-hash".to_string(),
size: 99,
resource_type: ResourceType::AssetBundle,
},
ResourceEntry {
path: "synthetic/catalog.json".to_string(),
hash: "synthetic-catalog-hash".to_string(),
size: 1,
resource_type: ResourceType::Manifest,
},
],
metadata: ManifestMetadata {
locator_id: None,
cdn_prefixes: Vec::new(),
extra: HashMap::new(),
},
}
}
#[tokio::test]
async fn imports_synthetic_manifest_bundles_into_cas_and_resource_repository() {
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let report = service
.import_manifest_bundles(
&synthetic_manifest(),
&[BundleSource::new(
"minimal.bundle",
synthetic_minimal_unityfs_bundle(),
)],
)
.await
.unwrap();
assert_eq!(report.imported.len(), 1);
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
assert!(cas.exists(&report.imported[0].object_id).await);
assert_eq!(report.imported[0].unityfs.unity_version, "2021.3.56f2");
assert_eq!(report.imported[0].unityfs.block_count, 1);
assert_eq!(report.imported[0].unityfs.directory_count, 1);
assert_eq!(
report.imported[0].unityfs.directories,
vec!["SYNTHETIC-CAB".to_string()]
);
let indexed = resources.list(ResourceQuery::all()).await.unwrap();
assert_eq!(indexed.len(), 1);
assert_eq!(indexed[0].entry.hash, report.imported[0].object_id);
assert_eq!(
indexed[0].entry.size,
synthetic_minimal_unityfs_bundle().len() as u64
);
}
#[tokio::test]
async fn returns_error_when_bundle_data_is_missing() {
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let error = service
.import_manifest_bundles(&synthetic_manifest(), &[])
.await
.unwrap_err();
assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
}
#[tokio::test]
async fn returns_error_when_bundle_is_not_unityfs() {
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let error = service
.import_manifest_bundles(
&synthetic_manifest(),
&[BundleSource::new(
"minimal.bundle",
b"not-a-unityfs-bundle".to_vec(),
)],
)
.await
.unwrap_err();
assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
assert_eq!(resources.count(ResourceQuery::all()).await.unwrap(), 0);
}
}