feat(assetbundle): 完成已验证结构的重建发布闭环
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-10 00:46:48 +08:00
parent 68c6c91b1e
commit 69b6e36bf0
21 changed files with 1588 additions and 101 deletions
+16 -4
View File
@@ -5098,6 +5098,7 @@ fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<&
&& options.config.curl_command == defaults.curl_command
&& options.config.curl_proxy == defaults.curl_proxy
&& options.config.unzip_command == defaults.unzip_command
&& options.config.zip_command == defaults.zip_command
&& !options.config.dry_run
&& !options.config.plan
&& options.config.audit_local == defaults.audit_local
@@ -5351,6 +5352,7 @@ fn build_doctor_report(
command_check("curl", &config.curl_command),
proxy_check(&config.curl_proxy),
command_check("unzip", &config.unzip_command),
command_check("zip", &config.zip_command),
];
let pid_path = daemon_pid_path(state_dir);
@@ -6246,6 +6248,8 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
}
args.push("--unzip".to_string());
args.push(config.unzip_command.to_string_lossy().to_string());
args.push("--zip".to_string());
args.push(config.zip_command.to_string_lossy().to_string());
if config.force {
args.push("--force".to_string());
}
@@ -6575,6 +6579,9 @@ fn apply_bat_env_overrides(
if let Some(v) = value("BAT_UNZIP") {
options.config.unzip_command = PathBuf::from(v);
}
if let Some(v) = value("BAT_ZIP") {
options.config.zip_command = PathBuf::from(v);
}
if let Some(v) = value("BAT_TRANSLATION_PROVIDER") {
options.translation_provider = Some(v);
}
@@ -7077,6 +7084,9 @@ fn parse_args_with_env(
"--unzip" => {
options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?);
}
"--zip" => {
options.config.zip_command = PathBuf::from(next_option_value(&mut args, &flag)?);
}
"--dry-run" => {
options.config.dry_run = true;
options.sync_option_explicit = true;
@@ -7744,7 +7754,7 @@ fn parse_args_with_env(
CliCommand::Doctor => {
if options.sync_option_explicit {
return Err(anyhow::anyhow!(
"doctor 只接受 --output、--state-dir、--curl、--proxy 和 --unzip 等诊断参数"
"doctor 只接受 --output、--state-dir、--curl、--proxy、--unzip 和 --zip 等诊断参数"
));
}
options.progress = false;
@@ -7753,7 +7763,7 @@ fn parse_args_with_env(
CliCommand::DoctorCas => {
if doctor_cas_has_disallowed_sync_options(&options) {
return Err(anyhow::anyhow!(
"doctor cas 只接受 --output、--import-repository、--import-cas-root、--import-resource-db、--state-dir、--curl、--proxy 和 --unzip 等诊断参数"
"doctor cas 只接受 --output、--import-repository、--import-cas-root、--import-resource-db、--state-dir、--curl、--proxy、--unzip 和 --zip 等诊断参数"
));
}
options.progress = false;
@@ -7762,7 +7772,7 @@ fn parse_args_with_env(
CliCommand::CleanStable => {
if options.sync_option_explicit {
return Err(anyhow::anyhow!(
"doctor/clean-stable 只接受 --output、--state-dir、--curl、--proxy 和 --unzip 等诊断参数"
"doctor/clean-stable 只接受 --output、--state-dir、--curl、--proxy、--unzip 和 --zip 等诊断参数"
));
}
options.progress = false;
@@ -8181,6 +8191,7 @@ fn doctor_cas_has_disallowed_sync_options(options: &CliOptions) -> bool {
allowed.curl_command = options.config.curl_command.clone();
allowed.curl_proxy = options.config.curl_proxy.clone();
allowed.unzip_command = options.config.unzip_command.clone();
allowed.zip_command = options.config.zip_command.clone();
allowed.import_repository = options.config.import_repository;
allowed.import_cas_root = options.config.import_cas_root.clone();
allowed.import_resource_repository_path =
@@ -8412,12 +8423,13 @@ fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()>
}
}
/// 判断 curl/代理/unzip 是否偏离基线。基线是 `config.toml` 和环境变量应用后的
/// 判断 curl/代理/unzip/zip 是否偏离基线。基线是 `config.toml` 和环境变量应用后的
/// 配置快照,因此只有命令行显式传入才算"非默认"。
fn tools_are_non_default(config: &OfficialUpdateConfig, baseline: &OfficialUpdateConfig) -> bool {
config.curl_command != baseline.curl_command
|| config.curl_proxy != baseline.curl_proxy
|| config.unzip_command != baseline.unzip_command
|| config.zip_command != baseline.zip_command
}
fn translation_memory_has_unsupported_query_filters(options: &CliOptions) -> bool {
+8
View File
@@ -1537,6 +1537,8 @@ fn parses_explicit_source_and_disable_repair() {
"--no-repair",
"--unzip",
"/usr/bin/unzip",
"--zip",
"/usr/bin/zip",
])
.unwrap();
let config = options.config;
@@ -1547,6 +1549,7 @@ fn parses_explicit_source_and_disable_repair() {
assert!(!config.audit_local);
assert!(!config.repair);
assert_eq!(config.unzip_command, PathBuf::from("/usr/bin/unzip"));
assert_eq!(config.zip_command, PathBuf::from("/usr/bin/zip"));
assert!(matches!(
config.server_info_source,
Some(OfficialServerInfoSource::OfficialUrl(ref url))
@@ -2226,6 +2229,8 @@ fn daemon_child_args_preserve_sync_options() {
"http://127.0.0.1:7890",
"--unzip",
"/usr/bin/unzip",
"--zip",
"/usr/bin/zip",
"--interval",
"30m",
"--error-retry",
@@ -2242,6 +2247,9 @@ fn daemon_child_args_preserve_sync_options() {
assert!(args
.windows(2)
.any(|pair| pair == ["--output", "/tmp/daemon-output"]));
assert!(args
.windows(2)
.any(|pair| pair == ["--zip", "/usr/bin/zip"]));
assert!(args
.windows(2)
.any(|pair| pair == ["--localized-output", "/tmp/daemon-localized"]));
+13
View File
@@ -61,6 +61,7 @@ import_resource_repository_path = ''
curl_command = 'curl'
proxy = 'auto'
unzip_command = 'unzip'
zip_command = 'zip'
download_concurrency = 8
[translation.worker]
@@ -138,6 +139,7 @@ struct NetworkSection {
curl_command: Option<PathBuf>,
proxy: Option<CurlProxyConfig>,
unzip_command: Option<PathBuf>,
zip_command: Option<PathBuf>,
download_concurrency: Option<usize>,
}
@@ -356,6 +358,9 @@ impl BatConfigFile {
if let Some(value) = self.network.unzip_command.as_ref() {
options.config.unzip_command = value.clone();
}
if let Some(value) = self.network.zip_command.as_ref() {
options.config.zip_command = value.clone();
}
if let Some(value) = self.network.download_concurrency {
options.config.download_concurrency = value;
}
@@ -575,6 +580,13 @@ impl BatConfigFile {
line_number,
)?);
}
(SectionPath::Network, "zip_command") => {
self.network.zip_command = Some(parse_required_path(
value,
"network.zip_command",
line_number,
)?);
}
(SectionPath::Network, "download_concurrency") => {
self.network.download_concurrency = Some(parse_download_concurrency(
&parse_scalar_text(value, "network.download_concurrency", line_number)?,
@@ -1151,6 +1163,7 @@ import_resource_repository_path = '/srv/resources.sqlite'
curl_command = '/usr/bin/curl'
proxy = 'http://127.0.0.1:7890'
unzip_command = '/usr/bin/unzip'
zip_command = '/usr/bin/zip'
download_concurrency = 16
[translation.worker]
@@ -422,6 +422,7 @@ Sync:
--proxy <URL|auto|none> curl proxy override (default: auto from env)
--no-proxy Force direct curl connections
--unzip <PATH> unzip executable (default: unzip)
--zip <PATH> zip executable (default: zip)
--dry-run Do not write sync state
--plan Include planned URLs in dry-run
--force Force download/refresh
@@ -390,6 +390,10 @@ pub(super) fn publish_localized_report(
official_release_id,
Vec::new(),
)
.with_archive_commands(
options.config.unzip_command.clone(),
options.config.zip_command.clone(),
)
.with_operations(operations)
.with_force(options.config.force);
if let Some(release_id) = localized_release_id {
+642 -15
View File
@@ -1,19 +1,21 @@
//! Localized release publishing for verified UnityFS text patches.
use bat_assetbundle::{
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
StringFieldPatch, TextAssetPatch,
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch, Parser,
StringFieldPatch, TextAssetPatch, UnitySerializedField, UnitySerializedValue,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::path_security::{
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
read_file_no_symlink, write_file_atomic, STATE_FILE_MODE,
};
use crate::zip_validation::validate_zip_structure;
/// Atomic current pointer under the localized output root.
pub const LOCALIZED_CURRENT_LINK: &str = "current";
@@ -39,6 +41,8 @@ pub const LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL: &str = "人工
pub struct LocalizedTextAssetPatch {
/// Relative path of the UnityFS bundle under the official release.
pub bundle_path: String,
/// Relative path of the UnityFS bundle inside an outer ZIP archive.
pub archive_entry: Option<String>,
/// TextAsset replacement inside the bundle.
pub text_asset: TextAssetPatch,
/// TextUnit/provider metadata recorded in the localized manifest.
@@ -50,6 +54,8 @@ pub struct LocalizedTextAssetPatch {
pub struct LocalizedStringFieldPatch {
/// Relative path of the UnityFS bundle under the official release.
pub bundle_path: String,
/// Relative path of the UnityFS bundle inside an outer ZIP archive.
pub archive_entry: Option<String>,
/// String field replacement inside the bundle.
pub string_field: StringFieldPatch,
/// TextUnit/provider metadata recorded in the localized manifest.
@@ -61,6 +67,8 @@ pub struct LocalizedStringFieldPatch {
pub struct LocalizedFieldPatch {
/// Relative path of the UnityFS bundle under the official release.
pub bundle_path: String,
/// Relative path of the UnityFS bundle inside an outer ZIP archive.
pub archive_entry: Option<String>,
/// Semantic field replacement inside the bundle.
pub field: FieldPatch,
/// TextUnit/provider metadata recorded in the localized manifest.
@@ -127,6 +135,10 @@ pub struct LocalizedPatchConfig {
pub patches: Vec<LocalizedTextAssetPatch>,
/// General UnityFS text/field operations to apply.
pub operations: Vec<LocalizedPatchInput>,
/// Unzip executable used for ZIP-inner bundle publication.
pub unzip_command: PathBuf,
/// Zip executable used for ZIP-inner bundle publication.
pub zip_command: PathBuf,
}
impl LocalizedPatchConfig {
@@ -145,6 +157,8 @@ impl LocalizedPatchConfig {
force: false,
patches,
operations: Vec::new(),
unzip_command: PathBuf::from("unzip"),
zip_command: PathBuf::from("zip"),
}
}
@@ -166,6 +180,17 @@ impl LocalizedPatchConfig {
self
}
/// Sets the archive tools used for ZIP-inner bundle publication.
pub fn with_archive_commands(
mut self,
unzip_command: impl Into<PathBuf>,
zip_command: impl Into<PathBuf>,
) -> Self {
self.unzip_command = unzip_command.into();
self.zip_command = zip_command.into();
self
}
fn published_release_id(&self) -> &str {
self.localized_release_id
.as_deref()
@@ -193,6 +218,14 @@ impl LocalizedPatchInput {
}
}
fn archive_entry(&self) -> Option<&str> {
match self {
Self::TextAsset(operation) => operation.archive_entry.as_deref(),
Self::StringField(operation) => operation.archive_entry.as_deref(),
Self::Field(operation) => operation.archive_entry.as_deref(),
}
}
fn apply(&self, input: &[u8]) -> anyhow::Result<Vec<u8>> {
match self {
Self::TextAsset(operation) => {
@@ -206,7 +239,7 @@ impl LocalizedPatchInput {
}
fn manifest_operation(&self) -> anyhow::Result<LocalizedPatchOperation> {
match self {
let mut operation = match self {
Self::TextAsset(operation) => Ok(LocalizedPatchOperation::from_text_asset_patch(
&operation.text_asset,
operation.metadata.as_ref(),
@@ -219,7 +252,9 @@ impl LocalizedPatchInput {
&operation.field,
operation.metadata.as_ref(),
),
}
}?;
operation.archive_entry = self.archive_entry().map(str::to_string);
Ok(operation)
}
}
@@ -314,6 +349,9 @@ pub struct LocalizedPatchFile {
/// One TextAsset patch operation recorded in the localized patch manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchOperation {
/// Relative path of the UnityFS bundle inside an outer ZIP archive.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive_entry: Option<String>,
/// Patch operation kind, for example `unityfs_text_asset`.
#[serde(default = "default_patch_operation_kind")]
pub patch_kind: String,
@@ -499,6 +537,11 @@ impl LocalizedPatchService {
.join(&published_release_id);
let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
validate_config(config).map_err(anyhow::Error::msg)?;
let state_path = config
.localized_output_root
.join(LOCALIZED_VERSION_STATE_FILE);
let previous_state_bytes =
read_file_no_symlink(&state_path, "汉化版本状态").map_err(anyhow::Error::msg)?;
let previous_current_target = current_symlink_target(&current_path)?;
if let Some(target) = previous_current_target.as_deref() {
validate_previous_current_target(&config.localized_output_root, target)?;
@@ -514,6 +557,7 @@ impl LocalizedPatchService {
!version_existed_before,
&current_path,
previous_current_target.as_ref(),
(&state_path, previous_state_bytes.as_deref()),
) {
return Err(anyhow::anyhow!(
"{error}; rollback failed: {rollback_error}"
@@ -751,14 +795,35 @@ impl LocalizedPatchService {
ensure_safe_file_target(&staging, &target, "汉化 patch 输入")
.map_err(anyhow::Error::msg)?;
let original = fs::read(&target)?;
let mut patched = original.clone();
let mut manifest_operations = Vec::with_capacity(operations.len());
for operation in operations {
patched = operation
.apply(&patched)
.map_err(|error| anyhow::anyhow!("{bundle_path}: {error}"))?;
manifest_operations.push(operation.manifest_operation()?);
}
let (patched, manifest_operations) = if operations
.iter()
.any(|operation| operation.archive_entry().is_some())
{
if operations
.iter()
.any(|operation| operation.archive_entry().is_none())
{
return Err(anyhow::anyhow!(
"{bundle_path}: 不能混合直接 bundle patch 与 ZIP 内 bundle patch"
));
}
rewrite_zip_bundle(
&original,
&operations,
&config.unzip_command,
&config.zip_command,
)?
} else {
let mut patched = original.clone();
let mut manifest_operations = Vec::with_capacity(operations.len());
for operation in operations {
patched = operation
.apply(&patched)
.map_err(|error| anyhow::anyhow!("{bundle_path}: {error}"))?;
manifest_operations.push(operation.manifest_operation()?);
}
(patched, manifest_operations)
};
if original == patched {
return Err(anyhow::anyhow!("patch produced no change: {bundle_path}"));
}
@@ -800,7 +865,12 @@ impl LocalizedPatchService {
"汉化 patch manifest",
)
.map_err(anyhow::Error::msg)?;
verify_patch_manifest_files(&config.official_release_root, &staging, &manifest)?;
verify_patch_manifest_files(
&config.official_release_root,
&staging,
&manifest,
&config.unzip_command,
)?;
fs::create_dir_all(config.localized_output_root.join(LOCALIZED_VERSIONS_DIR))?;
fs::rename(&staging, &version_path)?;
switch_current_symlink(
@@ -828,6 +898,7 @@ impl LocalizedPatchService {
&config.official_release_root,
&version_path,
&current_path,
&config.unzip_command,
)?;
Ok(LocalizedPatchReport {
@@ -954,6 +1025,7 @@ impl LocalizedPatchOperation {
) -> Self {
Self::with_metadata(
Self {
archive_entry: None,
patch_kind: "unityfs_text_asset".to_string(),
serialized_file_path: patch.serialized_file_path.clone(),
path_id: patch.path_id,
@@ -981,6 +1053,7 @@ impl LocalizedPatchOperation {
) -> Self {
Self::with_metadata(
Self {
archive_entry: None,
patch_kind: "unityfs_string_field".to_string(),
serialized_file_path: patch.serialized_file_path.clone(),
path_id: patch.path_id,
@@ -1011,6 +1084,7 @@ impl LocalizedPatchOperation {
let replacement = serde_json::to_vec(&patch.replacement)?;
Ok(Self::with_metadata(
Self {
archive_entry: None,
patch_kind: "unityfs_field".to_string(),
serialized_file_path: patch.serialized_file_path.clone(),
path_id: patch.path_id,
@@ -1058,6 +1132,7 @@ fn default_patch_operation_kind() -> String {
impl Default for LocalizedPatchOperation {
fn default() -> Self {
Self {
archive_entry: None,
patch_kind: default_patch_operation_kind(),
serialized_file_path: String::new(),
path_id: 0,
@@ -1082,6 +1157,7 @@ fn verify_published_localized_release(
official_release_root: &Path,
version_path: &Path,
current_path: &Path,
unzip_command: &Path,
) -> anyhow::Result<LocalizedPatchIntegrity> {
let manifest = read_localized_patch_manifest_at(version_path)?.ok_or_else(|| {
anyhow::anyhow!(
@@ -1089,8 +1165,12 @@ fn verify_published_localized_release(
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
)
})?;
let mut integrity =
verify_patch_manifest_files(official_release_root, version_path, &manifest)?;
let mut integrity = verify_patch_manifest_files(
official_release_root,
version_path,
&manifest,
unzip_command,
)?;
integrity.current_points_to_release = current_points_to_version(current_path, version_path)?;
if !integrity.current_points_to_release {
return Err(anyhow::anyhow!(
@@ -1106,6 +1186,7 @@ fn verify_patch_manifest_files(
official_release_root: &Path,
localized_release_root: &Path,
manifest: &LocalizedPatchManifest,
unzip_command: &Path,
) -> anyhow::Result<LocalizedPatchIntegrity> {
let mut operation_count = 0usize;
for file in &manifest.files {
@@ -1151,6 +1232,15 @@ fn verify_patch_manifest_files(
file.path
));
}
let archive_operations = file
.text_asset_operations
.iter()
.filter(|operation| operation.archive_entry.is_some())
.collect::<Vec<_>>();
if !archive_operations.is_empty() {
verify_zip_operations(unzip_command, &official_path, &archive_operations, false)?;
verify_zip_operations(unzip_command, &localized_path, &archive_operations, true)?;
}
operation_count += file.text_asset_operations.len();
}
if manifest.file_count != manifest.files.len() {
@@ -1174,6 +1264,316 @@ fn verify_patch_manifest_files(
})
}
fn rewrite_zip_bundle(
original: &[u8],
operations: &[LocalizedPatchInput],
unzip_command: &Path,
zip_command: &Path,
) -> anyhow::Result<(Vec<u8>, Vec<LocalizedPatchOperation>)> {
let archive_temp = tempfile::tempdir()?;
let archive_path = archive_temp.path().join("source.zip");
fs::write(&archive_path, original)?;
validate_zip_structure(&archive_path).map_err(anyhow::Error::msg)?;
let entries = list_archive_entries(unzip_command, &archive_path)?;
let extract_path = archive_temp.path().join("extract");
fs::create_dir(&extract_path)?;
let extract_output = Command::new(unzip_command)
.arg("-q")
.arg("-o")
.arg(&archive_path)
.arg("-d")
.arg(&extract_path)
.output()
.map_err(|error| anyhow::anyhow!("启动 unzip 解包 ZIP 失败:{error}"))?;
ensure_command_success(&extract_output, "解包 ZIP")?;
reject_extracted_symlinks(&extract_path)?;
let mut manifest_operations = Vec::with_capacity(operations.len());
let mut patched_entries = BTreeMap::<String, Vec<u8>>::new();
for operation in operations {
let archive_entry = operation.archive_entry().ok_or_else(|| {
anyhow::anyhow!("ZIP patch 缺少 archive_entry{}", operation.bundle_path())
})?;
validate_archive_member_name(archive_entry)?;
if !entries.contains(archive_entry) {
return Err(anyhow::anyhow!(
"ZIP 中不存在 archive entry{}!{}",
operation.bundle_path(),
archive_entry
));
}
let entry_path = extract_path.join(archive_entry);
ensure_path_within_root(&extract_path, &entry_path).map_err(anyhow::Error::msg)?;
ensure_safe_file_target(&extract_path, &entry_path, "ZIP 内 bundle")
.map_err(anyhow::Error::msg)?;
let current = if let Some(current) = patched_entries.get(archive_entry) {
current.clone()
} else {
fs::read(&entry_path)?
};
let patched = operation.apply(&current).map_err(|error| {
anyhow::anyhow!("{}!{}: {error}", operation.bundle_path(), archive_entry)
})?;
fs::write(&entry_path, &patched)?;
patched_entries.insert(archive_entry.to_string(), patched);
manifest_operations.push(operation.manifest_operation()?);
}
let output_path = archive_temp.path().join("localized.zip");
let zip_output = Command::new(zip_command)
.current_dir(&extract_path)
.arg("-q")
.arg("-r")
.arg(&output_path)
.arg(".")
.output()
.map_err(|error| anyhow::anyhow!("启动 zip 重打包 ZIP 失败:{error}"))?;
ensure_command_success(&zip_output, "重打包 ZIP")?;
validate_zip_structure(&output_path).map_err(anyhow::Error::msg)?;
let patched = fs::read(output_path)?;
Ok((patched, manifest_operations))
}
fn list_archive_entries(
unzip_command: &Path,
archive_path: &Path,
) -> anyhow::Result<std::collections::BTreeSet<String>> {
let output = Command::new(unzip_command)
.arg("-Z1")
.arg(archive_path)
.output()
.map_err(|error| anyhow::anyhow!("启动 unzip 列出 ZIP 条目失败:{error}"))?;
ensure_command_success(&output, "列出 ZIP 条目")?;
let entries = std::str::from_utf8(&output.stdout)
.map_err(|_| anyhow::anyhow!("ZIP 条目列表不是 UTF-8{}", archive_path.display()))?
.lines()
.map(|line| line.trim_end_matches('\r').to_string())
.filter(|entry| !entry.is_empty() && !entry.ends_with('/'))
.collect::<std::collections::BTreeSet<_>>();
for entry in &entries {
validate_archive_member_name(entry)?;
}
Ok(entries)
}
fn verify_zip_operations(
unzip_command: &Path,
archive_path: &Path,
operations: &[&LocalizedPatchOperation],
verify_replacements: bool,
) -> anyhow::Result<()> {
validate_zip_structure(archive_path).map_err(anyhow::Error::msg)?;
let entries = list_archive_entries(unzip_command, archive_path)?;
for operation in operations {
let archive_entry = operation.archive_entry.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"manifest ZIP operation 缺少 archive_entry{}",
archive_path.display()
)
})?;
validate_archive_member_name(archive_entry)?;
if !entries.contains(archive_entry) {
return Err(anyhow::anyhow!(
"manifest ZIP operation 指向不存在的 archive entry{}!{}",
archive_path.display(),
archive_entry
));
}
let output = Command::new(unzip_command)
.arg("-p")
.arg(archive_path)
.arg(archive_entry)
.output()
.map_err(|error| anyhow::anyhow!("启动 unzip 读取 ZIP 条目失败:{error}"))?;
ensure_command_success(&output, "读取 ZIP 条目")?;
let parsed = bat_assetbundle::UnityFsParser::new()
.parse(&output.stdout)
.map_err(|error| {
anyhow::anyhow!(
"ZIP 内 UnityFS 重解析失败 {}!{}{error}",
archive_path.display(),
archive_entry
)
})?;
if verify_replacements {
verify_archive_operation_replacement(&parsed, operation, archive_entry)?;
}
}
Ok(())
}
fn verify_archive_operation_replacement(
parsed: &bat_assetbundle::ParsedAssetBundle,
operation: &LocalizedPatchOperation,
archive_entry: &str,
) -> anyhow::Result<()> {
let Some(serialized_file) = parsed
.serialized_files
.iter()
.find(|file| file.source_path.as_deref() == Some(operation.serialized_file_path.as_str()))
else {
return Err(anyhow::anyhow!(
"ZIP 内 UnityFS manifest serialized file 不存在:{}!{}:{}",
archive_entry,
operation.serialized_file_path,
operation.path_id
));
};
let bytes: Vec<u8> = match operation.patch_kind.as_str() {
"unityfs_text_asset" => parsed
.text_assets
.iter()
.find(|asset| {
asset.source_path.as_deref() == Some(operation.serialized_file_path.as_str())
&& asset.path_id == operation.path_id
})
.map(|asset| asset.bytes.clone())
.ok_or_else(|| {
anyhow::anyhow!(
"ZIP 内 UnityFS manifest TextAsset 不存在:{}!{}:{}",
archive_entry,
operation.serialized_file_path,
operation.path_id
)
})?,
"unityfs_string_field" => {
let field_path = operation.field_path.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"ZIP 内 UnityFS string operation 缺少 field_path{}!{}:{}",
archive_entry,
operation.serialized_file_path,
operation.path_id
)
})?;
let fields = serialized_file.fields_for_object(operation.path_id)?;
let value = find_archive_field_value(&fields, field_path).ok_or_else(|| {
anyhow::anyhow!(
"ZIP 内 UnityFS manifest 字段不存在:{}!{}:{}:{}",
archive_entry,
operation.serialized_file_path,
operation.path_id,
field_path
)
})?;
match value {
UnitySerializedValue::String(value) => value.as_bytes().to_vec(),
other => {
return Err(anyhow::anyhow!(
"ZIP 内 UnityFS manifest 字段不是 string{}!{}:{}:{} ({other:?})",
archive_entry,
operation.serialized_file_path,
operation.path_id,
field_path
));
}
}
}
// Semantic field replacements retain structure and are already
// verified by the field patch operation before the archive rewrite.
_ => return Ok(()),
};
let actual_hash = blake3::hash(&bytes).to_hex().to_string();
if actual_hash != operation.replacement_blake3
|| bytes.len() as u64 != operation.replacement_bytes
{
return Err(anyhow::anyhow!(
"ZIP 内 UnityFS replacement 校验失败:{}!{}:{},期望 hash={} bytes={},实际 hash={} bytes={}",
archive_entry,
operation.serialized_file_path,
operation.path_id,
operation.replacement_blake3,
operation.replacement_bytes,
actual_hash,
bytes.len()
));
}
Ok(())
}
fn find_archive_field_value<'a>(
fields: &'a [UnitySerializedField],
field_path: &str,
) -> Option<&'a UnitySerializedValue> {
for field in fields {
if field.path == field_path {
return Some(&field.value);
}
let children = match &field.value {
UnitySerializedValue::Object(children)
| UnitySerializedValue::Array(children)
| UnitySerializedValue::Map(children)
| UnitySerializedValue::ManagedReference {
fields: children, ..
}
| UnitySerializedValue::ManagedReferenceRegistry {
fields: children, ..
} => children,
_ => continue,
};
if let Some(value) = find_archive_field_value(children, field_path) {
return Some(value);
}
}
None
}
fn ensure_command_success(output: &std::process::Output, action: &str) -> anyhow::Result<()> {
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
Err(anyhow::anyhow!(
"{action}失败:{}{}",
stderr,
if stdout.is_empty() {
String::new()
} else {
format!(";输出={stdout}")
}
))
}
fn validate_archive_member_name(name: &str) -> anyhow::Result<()> {
let is_windows_absolute = name.as_bytes().get(1) == Some(&b':');
if name.is_empty() || name.contains('\0') || name.contains('\\') || is_windows_absolute {
return Err(anyhow::anyhow!("ZIP 条目路径无效:{name}"));
}
let path = Path::new(name);
for component in path.components() {
match component {
std::path::Component::Normal(_) | std::path::Component::CurDir => {}
_ => return Err(anyhow::anyhow!("ZIP 条目路径不安全:{name}")),
}
}
Ok(())
}
fn reject_extracted_symlinks(root: &Path) -> anyhow::Result<()> {
for entry in fs::read_dir(root)? {
let path = entry?.path();
let metadata = fs::symlink_metadata(&path)?;
if metadata.file_type().is_symlink() {
return Err(anyhow::anyhow!(
"ZIP 解包结果包含 symlink,拒绝重打包:{}",
path.display()
));
}
if metadata.is_dir() {
reject_extracted_symlinks(&path)?;
} else if !metadata.is_file() {
return Err(anyhow::anyhow!(
"ZIP 解包结果包含非普通文件:{}",
path.display()
));
}
}
Ok(())
}
fn current_symlink_target(current_path: &Path) -> anyhow::Result<Option<PathBuf>> {
match fs::symlink_metadata(current_path) {
Ok(metadata) if metadata.file_type().is_symlink() => Ok(Some(fs::read_link(current_path)?)),
@@ -1232,7 +1632,9 @@ fn rollback_failed_publish(
remove_version_path: bool,
current_path: &Path,
previous_current_target: Option<&PathBuf>,
state_restore: (&Path, Option<&[u8]>),
) -> anyhow::Result<()> {
let (state_path, previous_state_bytes) = state_restore;
remove_owned_path(staging)?;
if remove_version_path {
remove_owned_path(version_path)?;
@@ -1249,6 +1651,16 @@ fn rollback_failed_publish(
}) {
restore_current_symlink(localized_output_root, current_path, previous_current_target)?;
}
remove_owned_path(state_path)?;
if let Some(previous_state_bytes) = previous_state_bytes {
write_file_atomic(
state_path,
previous_state_bytes,
STATE_FILE_MODE,
"恢复汉化版本状态",
)
.map_err(anyhow::Error::msg)?;
}
Ok(())
}
@@ -1415,6 +1827,110 @@ mod tests {
use super::*;
use tempfile::TempDir;
fn push_u32_be(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64_be(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}
fn align_vec(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn push_c_string(data: &mut Vec<u8>, value: &str) {
data.extend_from_slice(value.as_bytes());
data.push(0);
}
fn synthetic_serialized_text_asset(bytes: &[u8]) -> Vec<u8> {
let mut object_data = Vec::new();
push_u32_le(&mut object_data, 8);
object_data.extend_from_slice(b"Scenario");
align_vec(&mut object_data, 4);
push_u32_le(&mut object_data, bytes.len() as u32);
object_data.extend_from_slice(bytes);
let mut metadata = Vec::new();
metadata.extend_from_slice(b"2021.3.56f2\0");
metadata.extend_from_slice(&19i32.to_le_bytes());
metadata.push(0);
metadata.extend_from_slice(&1i32.to_le_bytes());
metadata.extend_from_slice(&49i32.to_le_bytes());
metadata.push(0);
push_i16_le(&mut metadata, 0);
metadata.extend_from_slice(&[0; 16]);
metadata.extend_from_slice(&1i32.to_le_bytes());
align_vec(&mut metadata, 4);
push_u64_le(&mut metadata, 1);
push_u64_le(&mut metadata, 0);
push_u32_le(&mut metadata, object_data.len() as u32);
push_u32_le(&mut metadata, 0);
let header_len = 48usize;
let data_offset = header_len + metadata.len();
let file_size = data_offset + object_data.len();
let mut file = Vec::new();
push_u32_be(&mut file, metadata.len() as u32);
push_u32_be(&mut file, file_size as u32);
push_u32_be(&mut file, 22);
push_u32_be(&mut file, 0);
file.extend_from_slice(&[0, 0, 0, 0]);
push_u32_be(&mut file, metadata.len() as u32);
push_u64_be(&mut file, file_size as u64);
push_u64_be(&mut file, data_offset as u64);
push_u64_be(&mut file, 0);
file.extend_from_slice(&metadata);
file.extend_from_slice(&object_data);
file
}
fn synthetic_unityfs(payload: &[u8]) -> Vec<u8> {
let mut blocks_info = vec![0; 16];
blocks_info.extend_from_slice(&1i32.to_be_bytes());
push_u32_be(&mut blocks_info, payload.len() as u32);
push_u32_be(&mut blocks_info, payload.len() as u32);
blocks_info.extend_from_slice(&0u16.to_be_bytes());
blocks_info.extend_from_slice(&1i32.to_be_bytes());
push_u64_be(&mut blocks_info, 0);
push_u64_be(&mut blocks_info, payload.len() as u64);
push_u32_be(&mut blocks_info, 0);
push_c_string(&mut blocks_info, "CAB-asset");
let mut file = Vec::new();
push_c_string(&mut file, "UnityFS");
push_u32_be(&mut file, 8);
push_c_string(&mut file, "5.x.x");
push_c_string(&mut file, "2021.3.56f2");
let total_size_offset = file.len();
push_u64_be(&mut file, 0);
push_u32_be(&mut file, blocks_info.len() as u32);
push_u32_be(&mut file, blocks_info.len() as u32);
push_u32_be(&mut file, 0);
align_vec(&mut file, 16);
file.extend_from_slice(&blocks_info);
file.extend_from_slice(payload);
let total_size = file.len() as u64;
file[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
file
}
#[test]
fn localized_manifest_converts_to_generic_patch_manifest() {
let source = b"source";
@@ -1434,6 +1950,7 @@ mod tests {
localized_bytes: target.len() as u64,
byte_delta: target.len() as i64 - source.len() as i64,
text_asset_operations: vec![LocalizedPatchOperation {
archive_entry: None,
patch_kind: "unityfs_text_asset".to_string(),
serialized_file_path: "CAB-asset".to_string(),
path_id: 1,
@@ -1489,6 +2006,7 @@ mod tests {
};
let input = LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch {
bundle_path: "Bundles/file.bundle".to_string(),
archive_entry: None,
text_asset: TextAssetPatch::new("CAB-asset", 1, b"target".to_vec()),
metadata: Some(LocalizedPatchOperationMetadata {
text_unit_id: "unit-1".to_string(),
@@ -1512,6 +2030,69 @@ mod tests {
);
}
#[cfg(unix)]
#[test]
fn publishes_and_reparses_bundle_nested_in_zip() {
let temp = TempDir::new().unwrap();
let official = temp.path().join("official-release");
let localized = temp.path().join("localized");
let zip_source = temp.path().join("zip-source");
fs::create_dir_all(zip_source.join("nested")).unwrap();
fs::create_dir_all(official.join("Bundles")).unwrap();
fs::write(
zip_source.join("nested/CAB-asset"),
synthetic_unityfs(&synthetic_serialized_text_asset(b"old")),
)
.unwrap();
let archive_path = official.join("Bundles/catalog.zip");
let zip_status = std::process::Command::new("zip")
.current_dir(&zip_source)
.arg("-q")
.arg("-r")
.arg(&archive_path)
.arg(".")
.status()
.unwrap();
assert!(zip_status.success());
let operation = LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch {
bundle_path: "Bundles/catalog.zip".to_string(),
archive_entry: Some("nested/CAB-asset".to_string()),
text_asset: TextAssetPatch::new("CAB-asset", 1, b"translated".to_vec()),
metadata: None,
});
let report = LocalizedPatchService::new()
.publish(
&LocalizedPatchConfig::new(&official, &localized, "release-1", Vec::new())
.with_archive_commands("unzip", "zip")
.with_operations(vec![operation]),
)
.unwrap();
let localized_archive = report.version_path.join("Bundles/catalog.zip");
let output = std::process::Command::new("unzip")
.arg("-p")
.arg(&localized_archive)
.arg("nested/CAB-asset")
.output()
.unwrap();
assert!(output.status.success());
let parsed = bat_assetbundle::UnityFsParser::new()
.parse(&output.stdout)
.unwrap();
assert_eq!(parsed.text_assets[0].bytes, b"translated");
assert_eq!(
report.manifest.files[0].text_asset_operations[0]
.archive_entry
.as_deref(),
Some("nested/CAB-asset")
);
assert_eq!(
fs::read_link(report.current_path).unwrap(),
PathBuf::from("versions/release-1")
);
}
#[cfg(unix)]
#[test]
fn publishes_a_separate_localized_release_atomically() {
@@ -1709,6 +2290,7 @@ mod tests {
"release-1",
vec![LocalizedTextAssetPatch {
bundle_path: "Bundles/bad.bundle".to_string(),
archive_entry: None,
text_asset: TextAssetPatch::new("CAB-bad", 1, b"replacement".to_vec()),
metadata: None,
}],
@@ -1726,4 +2308,49 @@ mod tests {
.exists());
assert!(!localized.join(LOCALIZED_CURRENT_LINK).exists());
}
#[cfg(unix)]
#[test]
fn failed_publish_rollback_restores_previous_state_file() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let localized = temp.path().join("localized");
let versions = localized.join(LOCALIZED_VERSIONS_DIR);
let staging = localized.join(LOCALIZED_STAGING_DIR).join("release-new");
let previous_version = versions.join("release-old");
let failed_version = versions.join("release-new");
let current = localized.join(LOCALIZED_CURRENT_LINK);
let state_path = localized.join(LOCALIZED_VERSION_STATE_FILE);
let previous_state = br#"{"state_version":1,"status":"localized"}"#;
fs::create_dir_all(&previous_version).unwrap();
fs::create_dir_all(&failed_version).unwrap();
fs::create_dir_all(&staging).unwrap();
fs::write(&state_path, previous_state).unwrap();
symlink(
Path::new(LOCALIZED_VERSIONS_DIR).join("release-new"),
&current,
)
.unwrap();
rollback_failed_publish(
&localized,
&staging,
&failed_version,
true,
&current,
Some(&PathBuf::from("versions/release-old")),
(&state_path, Some(previous_state)),
)
.unwrap();
assert!(!failed_version.exists());
assert!(!staging.exists());
assert_eq!(
fs::read_link(&current).unwrap(),
PathBuf::from("versions/release-old")
);
assert_eq!(fs::read(&state_path).unwrap(), previous_state);
}
}
+3
View File
@@ -126,6 +126,8 @@ pub struct OfficialUpdateConfig {
pub download_concurrency: usize,
/// Unzip command used when a metadata change requires GameMainConfig parsing.
pub unzip_command: PathBuf,
/// Zip command used when publishing localized bundles nested in ZIP archives.
pub zip_command: PathBuf,
/// Dry run reports decisions and optional plan URLs without writing sync state.
pub dry_run: bool,
/// Include full download URLs when dry-running.
@@ -162,6 +164,7 @@ impl Default for OfficialUpdateConfig {
curl_proxy: CurlProxyConfig::default(),
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
unzip_command: PathBuf::from("unzip"),
zip_command: PathBuf::from("zip"),
dry_run: false,
plan: false,
force: false,
+15 -22
View File
@@ -475,11 +475,10 @@ pub fn validate_translation_workbench_with_glossary_path(
}
changed_entries += 1;
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
let is_publishable = entry.archive_entry.is_none()
&& matches!(
source_kind.as_deref(),
Some("textasset" | "typetreefield" | "managedreferencefield")
);
let is_publishable = matches!(
source_kind.as_deref(),
Some("textasset" | "typetreefield" | "managedreferencefield")
);
if is_publishable {
let serialized_file = entry
.serialized_file
@@ -579,10 +578,7 @@ fn validate_current_glossary_qa(
}
/// Converts reviewed entries to localized patch operations supported by the
/// current UnityFS write layer.
///
/// ZIP-inner bundles are intentionally rejected here because they require a
/// separate archive rewrite boundary.
/// current UnityFS write layer and its ZIP rewrite boundary.
pub fn localized_patch_operations(
resource_root: &Path,
workbench: &TranslationWorkbench,
@@ -654,16 +650,11 @@ pub fn localized_patch_operations_with_glossary_path(
entry.id
));
};
if entry.archive_entry.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 位于 zip archive entry,当前 publish-localized 不支持直接修改 zip 内 bundle",
entry.id
));
}
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
let field_path = entry.field_path.clone();
if !seen.insert((
entry.destination.clone(),
entry.archive_entry.clone(),
serialized_file.clone(),
path_id,
field_path.clone(),
@@ -688,6 +679,7 @@ pub fn localized_patch_operations_with_glossary_path(
patch.expected_name = entry.asset_name.clone();
operations.push(LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch {
bundle_path: entry.destination.clone(),
archive_entry: entry.archive_entry.clone(),
text_asset: patch,
metadata,
}));
@@ -702,6 +694,7 @@ pub fn localized_patch_operations_with_glossary_path(
operations.push(LocalizedPatchInput::StringField(
LocalizedStringFieldPatch {
bundle_path: entry.destination.clone(),
archive_entry: entry.archive_entry.clone(),
string_field: StringFieldPatch {
serialized_file_path: serialized_file,
path_id,
@@ -771,12 +764,6 @@ pub fn localized_text_asset_patches(
entry.id
));
};
if entry.archive_entry.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 位于 zip archive entry,当前 publish-localized 不支持直接修改 zip 内 bundle",
entry.id
));
}
if normalized_text_source_kind(entry.text_source_kind.as_deref()).as_deref()
!= Some("textasset")
{
@@ -785,7 +772,12 @@ pub fn localized_text_asset_patches(
entry.id
));
}
if !seen.insert((entry.destination.clone(), serialized_file.clone(), path_id)) {
if !seen.insert((
entry.destination.clone(),
entry.archive_entry.clone(),
serialized_file.clone(),
path_id,
)) {
return Err(anyhow::anyhow!(
"翻译工作台包含重复 patch 目标:{}",
entry.id
@@ -811,6 +803,7 @@ pub fn localized_text_asset_patches(
}
patches.push(LocalizedTextAssetPatch {
bundle_path: entry.destination.clone(),
archive_entry: entry.archive_entry.clone(),
text_asset: patch,
metadata: Some(localized_patch_metadata(
entry,