mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
feat(assetbundle): 完成已验证结构的重建发布闭环
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"]));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¤t_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,
|
||||
¤t_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,
|
||||
¤t_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(¤t).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"),
|
||||
¤t,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
rollback_failed_publish(
|
||||
&localized,
|
||||
&staging,
|
||||
&failed_version,
|
||||
true,
|
||||
¤t,
|
||||
Some(&PathBuf::from("versions/release-old")),
|
||||
(&state_path, Some(previous_state)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!failed_version.exists());
|
||||
assert!(!staging.exists());
|
||||
assert_eq!(
|
||||
fs::read_link(¤t).unwrap(),
|
||||
PathBuf::from("versions/release-old")
|
||||
);
|
||||
assert_eq!(fs::read(&state_path).unwrap(), previous_state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user