Files
BlueArchiveToolkit/infrastructure/src/localized_patch.rs
T
nyaKazuha 69b6e36bf0
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
feat(assetbundle): 完成已验证结构的重建发布闭环
2026-09-10 00:46:48 +08:00

2357 lines
91 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Localized release publishing for verified UnityFS text patches.
use bat_assetbundle::{
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";
/// Staging directory under the localized output root.
pub const LOCALIZED_STAGING_DIR: &str = ".staging";
/// Version directory under the localized output root.
pub const LOCALIZED_VERSIONS_DIR: &str = "versions";
/// Persisted localized release state file name.
pub const LOCALIZED_VERSION_STATE_FILE: &str = "localized-version-state.json";
/// Per-release patch manifest file name.
pub const LOCALIZED_PATCH_MANIFEST_FILE: &str = "localized-patch-manifest.json";
/// Current localized patch manifest schema version.
pub const LOCALIZED_PATCH_MANIFEST_VERSION: u32 = 1;
/// Current localized version state schema version.
pub const LOCALIZED_VERSION_STATE_VERSION: u32 = 1;
/// Stable marker for localized releases that are under human proofreading.
pub const LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING: &str = "manual_proofreading";
/// Human label for `LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING`.
pub const LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL: &str = "人工校对中";
/// One patch operation against a bundle in an official release.
#[derive(Debug, Clone, PartialEq, Eq)]
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.
pub metadata: Option<LocalizedPatchOperationMetadata>,
}
/// One TypeTree string patch operation against a bundle in an official release.
#[derive(Debug, Clone, PartialEq, Eq)]
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.
pub metadata: Option<LocalizedPatchOperationMetadata>,
}
/// One semantic TypeTree patch operation against a bundle in an official release.
#[derive(Debug, Clone, PartialEq, Eq)]
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.
pub metadata: Option<LocalizedPatchOperationMetadata>,
}
/// Supported localized UnityFS patch operation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalizedPatchInput {
/// Replace a TextAsset payload.
TextAsset(LocalizedTextAssetPatch),
/// Replace a TypeTree string field.
StringField(LocalizedStringFieldPatch),
/// Replace a supported semantic TypeTree field.
Field(LocalizedFieldPatch),
}
/// Trace metadata for one localized patch operation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchOperationMetadata {
/// Stable TextUnit ID from the official TextUnit index.
pub text_unit_id: String,
/// BLAKE3 of the source text validated before publication.
pub source_text_blake3: String,
/// Translation provider that produced the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_provider: Option<String>,
/// Provider run ID that produced the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_run_id: Option<String>,
/// Source kind of the translation result.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_source_kind: Option<String>,
/// Trusted Translation Memory record used for the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_memory_record_id: Option<String>,
/// Review state used by the publication input.
pub review_status: String,
/// Deterministic Glossary QA recorded for this translation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<bat_core::domain::GlossaryQaReport>,
/// Explicit confirmation for a blocking Glossary deviation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<bat_core::domain::GlossaryOverride>,
}
/// Configuration for one localized release publication.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalizedPatchConfig {
/// Immutable, verified official release root.
pub official_release_root: PathBuf,
/// Separate localized publication root.
pub localized_output_root: PathBuf,
/// Version identifier shared with the official release.
pub release_id: String,
/// Optional distinct localized release ID. When omitted, `release_id` is
/// used for backward-compatible publication paths.
pub localized_release_id: Option<String>,
/// Allow publishing a new localized release even when the source release
/// already has a localized current release. The caller should normally
/// provide a distinct localized release ID.
pub force: bool,
/// Patch operations to apply.
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 {
/// Creates a localized patch configuration.
pub fn new(
official_release_root: impl Into<PathBuf>,
localized_output_root: impl Into<PathBuf>,
release_id: impl Into<String>,
patches: Vec<LocalizedTextAssetPatch>,
) -> Self {
Self {
official_release_root: official_release_root.into(),
localized_output_root: localized_output_root.into(),
release_id: release_id.into(),
localized_release_id: None,
force: false,
patches,
operations: Vec::new(),
unzip_command: PathBuf::from("unzip"),
zip_command: PathBuf::from("zip"),
}
}
/// Sets a distinct localized release ID.
pub fn with_localized_release_id(mut self, release_id: impl Into<String>) -> Self {
self.localized_release_id = Some(release_id.into());
self
}
/// Enables or disables forced publication.
pub fn with_force(mut self, force: bool) -> Self {
self.force = force;
self
}
/// Sets general localized patch operations.
pub fn with_operations(mut self, operations: Vec<LocalizedPatchInput>) -> Self {
self.operations = operations;
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()
.unwrap_or(&self.release_id)
}
fn patch_operations(&self) -> Vec<LocalizedPatchInput> {
let mut operations = self
.patches
.iter()
.cloned()
.map(LocalizedPatchInput::TextAsset)
.collect::<Vec<_>>();
operations.extend(self.operations.iter().cloned());
operations
}
}
impl LocalizedPatchInput {
fn bundle_path(&self) -> &str {
match self {
Self::TextAsset(operation) => &operation.bundle_path,
Self::StringField(operation) => &operation.bundle_path,
Self::Field(operation) => &operation.bundle_path,
}
}
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) => {
Ok(patch_unityfs_text_asset(input, &operation.text_asset)?)
}
Self::StringField(operation) => {
Ok(patch_unityfs_string_field(input, &operation.string_field)?)
}
Self::Field(operation) => Ok(patch_unityfs_field(input, &operation.field)?),
}
}
fn manifest_operation(&self) -> anyhow::Result<LocalizedPatchOperation> {
let mut operation = match self {
Self::TextAsset(operation) => Ok(LocalizedPatchOperation::from_text_asset_patch(
&operation.text_asset,
operation.metadata.as_ref(),
)),
Self::StringField(operation) => Ok(LocalizedPatchOperation::from_string_field_patch(
&operation.string_field,
operation.metadata.as_ref(),
)),
Self::Field(operation) => LocalizedPatchOperation::from_field_patch(
&operation.field,
operation.metadata.as_ref(),
),
}?;
operation.archive_entry = self.archive_entry().map(str::to_string);
Ok(operation)
}
}
/// Persisted localized release state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedVersionState {
/// State schema version.
#[serde(default = "default_localized_version_state_version")]
pub state_version: u32,
/// Official release ID used as the patch source.
pub official_release_id: String,
/// Published localized release ID.
pub current_release_id: Option<String>,
/// Stable status label.
pub status: String,
/// Review/proofreading workflow marker independent from publish status.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_workflow_status: Option<String>,
/// Last update time.
pub updated_unix_seconds: u64,
}
impl LocalizedVersionState {
/// Returns the stable translation workflow status, if set.
pub fn translation_workflow_status(&self) -> Option<&str> {
self.translation_workflow_status.as_deref()
}
/// Returns the user-facing translation workflow label, if set.
pub fn translation_workflow_label(&self) -> Option<&'static str> {
match self.translation_workflow_status() {
Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING) => {
Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL)
}
_ => None,
}
}
}
/// Report produced when changing localized translation workflow state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LocalizedTranslationWorkflowReport {
/// Stable command name.
pub command: &'static str,
/// Stable operation status.
pub status: &'static str,
/// Localized output root.
pub localized_output_root: PathBuf,
/// State file written under the localized output root.
pub state_path: PathBuf,
/// Official release associated with the workflow state.
pub official_release_id: String,
/// Current published localized release ID, when one exists.
pub current_release_id: Option<String>,
/// Stable localized publication label, such as `localized`.
pub localized_release_status: String,
/// Stable translation workflow marker.
pub translation_workflow_status: String,
/// Stable lifecycle status code for read-side callers.
pub translation_workflow_status_code: &'static str,
/// Human label for the workflow status.
pub translation_workflow_label: &'static str,
/// Whether an existing localized release remains publishable after the change.
pub publish_allowed: bool,
/// Last update time written to the state file.
pub updated_unix_seconds: u64,
}
/// One changed file in a localized patch manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchFile {
/// Relative bundle path.
pub path: String,
/// BLAKE3 before applying the patch.
pub original_blake3: String,
/// BLAKE3 after applying the patch.
pub localized_blake3: String,
/// Original file size in bytes.
#[serde(default)]
pub original_bytes: u64,
/// Localized file size in bytes.
#[serde(default)]
pub localized_bytes: u64,
/// Localized minus original byte size.
#[serde(default)]
pub byte_delta: i64,
/// TextAsset operations applied to this file.
#[serde(default)]
pub text_asset_operations: Vec<LocalizedPatchOperation>,
}
/// 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,
/// UnityFS directory path of the serialized file.
pub serialized_file_path: String,
/// Unity object path ID.
pub path_id: i64,
/// TypeTree field path for field-level patches.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field_path: Option<String>,
/// Expected TextAsset name, when provided.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_name: Option<String>,
/// Replacement payload size.
pub replacement_bytes: u64,
/// BLAKE3 of the replacement payload.
pub replacement_blake3: String,
/// Stable TextUnit ID from the official TextUnit index.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_unit_id: Option<String>,
/// BLAKE3 of the source text validated before publication.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_text_blake3: Option<String>,
/// Translation provider that produced the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_provider: Option<String>,
/// Provider run ID that produced the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_run_id: Option<String>,
/// Source kind of the translation result.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_source_kind: Option<String>,
/// Trusted Translation Memory record used for the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_memory_record_id: Option<String>,
/// Review state used by the publication input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_status: Option<String>,
/// Glossary QA recomputed for this publication.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<bat_core::domain::GlossaryQaReport>,
/// Human confirmation bound to the published Glossary QA identity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<bat_core::domain::GlossaryOverride>,
}
/// Rollback information recorded for a localized publication.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchRollbackInfo {
/// Previous `current` symlink target before this publication.
pub previous_current_target: Option<PathBuf>,
/// Version directory that should be removed when rolling this publication back.
pub remove_version_path: PathBuf,
}
/// Integrity summary for a published localized release.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchIntegrity {
/// Number of changed files verified against the manifest.
pub verified_changed_file_count: usize,
/// Number of TextAsset operations recorded in the manifest.
pub verified_text_asset_operation_count: usize,
/// Whether `current` points at this localized release.
pub current_points_to_release: bool,
}
/// Persisted manifest for one localized release.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchManifest {
/// Manifest schema version.
#[serde(default = "default_patch_manifest_version")]
pub manifest_version: u32,
/// Official release ID used as the patch source.
pub official_release_id: String,
/// Published localized release ID.
pub localized_release_id: String,
/// Manifest generation time as Unix seconds.
pub generated_unix_seconds: u64,
/// Changed file count.
pub file_count: usize,
/// TextAsset operation count.
pub text_asset_operation_count: usize,
/// Changed files and their before/after hashes.
pub files: Vec<LocalizedPatchFile>,
/// Rollback information for this release.
pub rollback: LocalizedPatchRollbackInfo,
}
impl LocalizedPatchManifest {
/// Converts the localized TextAsset manifest to the generic patch manifest model.
pub fn to_patch_manifest(&self) -> bat_patch::PatchManifest {
bat_patch::PatchManifest {
version: bat_patch::PATCH_MANIFEST_VERSION,
patch_id: self.localized_release_id.clone(),
source_version: self.official_release_id.clone(),
target_version: self.localized_release_id.clone(),
files: self
.files
.iter()
.map(|file| bat_patch::PatchManifestFile {
path: PathBuf::from(&file.path),
patch_kind: bat_patch::PatchKind::UnityFsTextAsset,
source_blake3: file.original_blake3.clone(),
target_blake3: file.localized_blake3.clone(),
source_size: file.original_bytes,
target_size: file.localized_bytes,
})
.collect(),
rollback: bat_patch::PatchRollback {
previous_current_target: self.rollback.previous_current_target.clone(),
remove_target_path: Some(self.rollback.remove_version_path.clone()),
},
}
}
}
/// Result of a successful localized release publication.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LocalizedPatchReport {
/// Published version directory.
pub version_path: PathBuf,
/// Atomic current pointer.
pub current_path: PathBuf,
/// Version state path.
pub state_path: PathBuf,
/// Patch manifest path.
pub patch_manifest_path: PathBuf,
/// Changed files.
pub files: Vec<LocalizedPatchFile>,
/// Persisted patch manifest.
pub manifest: LocalizedPatchManifest,
/// Integrity check performed after publication.
pub integrity: LocalizedPatchIntegrity,
}
/// Result of rolling back the current localized release.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LocalizedRollbackReport {
/// Stable command name.
pub command: &'static str,
/// Operation status.
pub status: &'static str,
/// Localized output root.
pub localized_output_root: PathBuf,
/// Atomic current pointer.
pub current_path: PathBuf,
/// Version state path.
pub state_path: PathBuf,
/// Localized release that was current before rollback.
pub rolled_back_release_id: String,
/// Removed current version directory.
pub removed_version_path: PathBuf,
/// Restored localized release ID, if a previous release existed.
#[serde(skip_serializing_if = "Option::is_none")]
pub restored_release_id: Option<String>,
/// Restored current symlink target, if a previous release existed.
#[serde(skip_serializing_if = "Option::is_none")]
pub restored_current_target: Option<PathBuf>,
/// New persisted localized version state.
pub state: LocalizedVersionState,
}
/// Applies supported UnityFS text patches and atomically publishes a localized release.
#[derive(Debug, Default, Clone, Copy)]
pub struct LocalizedPatchService;
impl LocalizedPatchService {
/// Creates the publisher.
pub fn new() -> Self {
Self
}
/// Copies the official release, applies patches in staging and publishes it.
pub fn publish(&self, config: &LocalizedPatchConfig) -> anyhow::Result<LocalizedPatchReport> {
let published_release_id = config.published_release_id().to_string();
let staging = config
.localized_output_root
.join(LOCALIZED_STAGING_DIR)
.join(&published_release_id);
let version_path = config
.localized_output_root
.join(LOCALIZED_VERSIONS_DIR)
.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)?;
}
let version_existed_before = version_path.exists();
match self.publish_inner(config, previous_current_target.clone()) {
Ok(report) => Ok(report),
Err(error) => {
if let Err(rollback_error) = rollback_failed_publish(
&config.localized_output_root,
&staging,
&version_path,
!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}"
));
}
Err(error)
}
}
}
/// Rolls back the current localized release to the manifest-recorded
/// previous current target.
pub fn rollback(
&self,
localized_output_root: &Path,
expected_release_id: Option<&str>,
) -> anyhow::Result<LocalizedRollbackReport> {
ensure_safe_directory_path(localized_output_root, "汉化输出目录")
.map_err(anyhow::Error::msg)?;
let versions_root = localized_output_root.join(LOCALIZED_VERSIONS_DIR);
ensure_safe_directory_path(&versions_root, "汉化 versions 目录")
.map_err(anyhow::Error::msg)?;
let current_path = localized_output_root.join(LOCALIZED_CURRENT_LINK);
let state_path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE);
let state = read_localized_version_state(localized_output_root)?.ok_or_else(|| {
anyhow::anyhow!("缺少汉化版本状态,无法 rollback{}", state_path.display())
})?;
let current_release_id = state
.current_release_id
.clone()
.ok_or_else(|| anyhow::anyhow!("当前没有已发布汉化 release,无法 rollback"))?;
if let Some(expected) = expected_release_id {
if expected != current_release_id {
return Err(anyhow::anyhow!(
"rollback 目标 release 不一致:当前={} 请求={}",
current_release_id,
expected
));
}
}
let version_path = localized_output_root
.join(LOCALIZED_VERSIONS_DIR)
.join(&current_release_id);
ensure_safe_directory_path(&version_path, "当前汉化 release")
.map_err(anyhow::Error::msg)?;
if !current_points_to_version(&current_path, &version_path)? {
return Err(anyhow::anyhow!(
"汉化 current 未指向当前状态 releasecurrent={} version={}",
current_path.display(),
version_path.display()
));
}
let manifest = read_localized_patch_manifest_at(&version_path)?.ok_or_else(|| {
anyhow::anyhow!(
"缺少当前汉化 release manifest{}",
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
)
})?;
if manifest.localized_release_id != current_release_id {
return Err(anyhow::anyhow!(
"manifest release={} 与当前状态 release={} 不一致",
manifest.localized_release_id,
current_release_id
));
}
if manifest.official_release_id != state.official_release_id {
return Err(anyhow::anyhow!(
"manifest 官方 release={} 与状态 release={} 不一致",
manifest.official_release_id,
state.official_release_id
));
}
let remove_version_path = manifest.rollback.remove_version_path.clone();
let expected_version_path = localized_output_root
.join(LOCALIZED_VERSIONS_DIR)
.join(&current_release_id);
if lexical_absolute(&remove_version_path).map_err(anyhow::Error::msg)?
!= lexical_absolute(&expected_version_path).map_err(anyhow::Error::msg)?
{
return Err(anyhow::anyhow!(
"rollback manifest 删除路径必须指向当前 release{}",
current_release_id
));
}
ensure_path_within_root(localized_output_root, &remove_version_path)
.map_err(anyhow::Error::msg)?;
let restored_release_id =
match manifest.rollback.previous_current_target.as_deref() {
None => None,
Some(target) => Some(release_id_from_current_target(target).ok_or_else(|| {
anyhow::anyhow!("rollback manifest 上一 current 目标格式无效")
})?),
};
let mut restored_manifest = None;
if let Some(previous_target) = manifest.rollback.previous_current_target.as_deref() {
let previous_path = localized_output_root.join(previous_target);
ensure_path_within_root(localized_output_root, &previous_path)
.map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(&previous_path, "上一汉化 release")
.map_err(anyhow::Error::msg)?;
if !previous_path.is_dir() {
return Err(anyhow::anyhow!(
"rollback 记录的上一汉化 release 不存在:{}",
previous_path.display()
));
}
restored_manifest = Some(
read_localized_patch_manifest_at(&previous_path)?.ok_or_else(|| {
anyhow::anyhow!(
"rollback 记录的上一汉化 release 缺少 manifest{}",
previous_path.display()
)
})?,
);
}
restore_current_symlink(
localized_output_root,
&current_path,
manifest.rollback.previous_current_target.as_ref(),
)?;
remove_owned_path(&remove_version_path)?;
let previous_official_release_id = state.official_release_id;
let previous_workflow_status = state.translation_workflow_status;
let restored_official_release_id = restored_manifest
.as_ref()
.map(|manifest| manifest.official_release_id.clone())
.unwrap_or_else(|| previous_official_release_id.clone());
let translation_workflow_status =
if restored_official_release_id == previous_official_release_id {
previous_workflow_status
} else {
None
};
let new_state = LocalizedVersionState {
state_version: LOCALIZED_VERSION_STATE_VERSION,
official_release_id: restored_official_release_id,
current_release_id: restored_release_id.clone(),
status: if restored_release_id.is_some() {
"localized".to_string()
} else {
"not_localized".to_string()
},
translation_workflow_status,
updated_unix_seconds: unix_seconds_now(),
};
write_localized_version_state(localized_output_root, &new_state)?;
Ok(LocalizedRollbackReport {
command: "localized.rollback",
status: "rolled_back",
localized_output_root: localized_output_root.to_path_buf(),
current_path,
state_path,
rolled_back_release_id: current_release_id,
removed_version_path: remove_version_path,
restored_release_id,
restored_current_target: manifest.rollback.previous_current_target,
state: new_state,
})
}
fn publish_inner(
&self,
config: &LocalizedPatchConfig,
previous_current_target: Option<PathBuf>,
) -> anyhow::Result<LocalizedPatchReport> {
validate_config(config).map_err(anyhow::Error::msg)?;
let staging = config
.localized_output_root
.join(LOCALIZED_STAGING_DIR)
.join(config.published_release_id());
let version_path = config
.localized_output_root
.join(LOCALIZED_VERSIONS_DIR)
.join(config.published_release_id());
let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
let state_path = config
.localized_output_root
.join(LOCALIZED_VERSION_STATE_FILE);
let versions_root = config.localized_output_root.join(LOCALIZED_VERSIONS_DIR);
let staging_root = config.localized_output_root.join(LOCALIZED_STAGING_DIR);
ensure_safe_directory_path(&versions_root, "汉化 versions 目录")
.map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(&staging_root, "汉化 staging 目录")
.map_err(anyhow::Error::msg)?;
let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE);
let previous_state = read_localized_version_state(&config.localized_output_root)?;
let translation_workflow_status = previous_state
.filter(|state| state.official_release_id == config.release_id)
.and_then(|state| state.translation_workflow_status);
match fs::symlink_metadata(&version_path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(anyhow::anyhow!(
"汉化 release 目标不能是 symlink{}",
version_path.display()
));
}
Ok(metadata) if !metadata.is_dir() => {
return Err(anyhow::anyhow!(
"汉化 release 目标已存在但不是目录:{}",
version_path.display()
));
}
Ok(_) => {
let message = if config.force {
"localized release target already exists; forced publication requires a distinct localized release id"
} else {
"localized release already exists"
};
return Err(anyhow::anyhow!("{message}: {}", version_path.display()));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
remove_owned_staging(&staging)?;
fs::create_dir_all(&staging)?;
copy_tree(&config.official_release_root, &staging)?;
let operations = config.patch_operations();
let mut grouped = BTreeMap::<String, Vec<LocalizedPatchInput>>::new();
for operation in operations {
grouped
.entry(operation.bundle_path().to_string())
.or_default()
.push(operation);
}
let mut changed_files = Vec::with_capacity(grouped.len());
for (bundle_path, operations) in grouped {
let target = staging.join(Path::new(&bundle_path));
ensure_path_within_root(&staging, &target).map_err(anyhow::Error::msg)?;
ensure_safe_file_target(&staging, &target, "汉化 patch 输入")
.map_err(anyhow::Error::msg)?;
let original = fs::read(&target)?;
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}"));
}
write_file_atomic(&target, &patched, STATE_FILE_MODE, "汉化 patch 输出")
.map_err(anyhow::Error::msg)?;
let original_blake3 = blake3::hash(&original).to_hex().to_string();
let localized_blake3 = blake3::hash(&patched).to_hex().to_string();
changed_files.push(LocalizedPatchFile {
path: bundle_path,
original_blake3,
localized_blake3,
original_bytes: original.len() as u64,
localized_bytes: patched.len() as u64,
byte_delta: patched.len() as i64 - original.len() as i64,
text_asset_operations: manifest_operations,
});
}
let manifest = LocalizedPatchManifest {
manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION,
official_release_id: config.release_id.clone(),
localized_release_id: config.published_release_id().to_string(),
generated_unix_seconds: unix_seconds_now(),
file_count: changed_files.len(),
text_asset_operation_count: changed_files
.iter()
.map(|file| file.text_asset_operations.len())
.sum(),
files: changed_files.clone(),
rollback: LocalizedPatchRollbackInfo {
previous_current_target: previous_current_target.clone(),
remove_version_path: version_path.clone(),
},
};
write_file_atomic(
&staging.join(LOCALIZED_PATCH_MANIFEST_FILE),
&serde_json::to_vec_pretty(&manifest)?,
STATE_FILE_MODE,
"汉化 patch manifest",
)
.map_err(anyhow::Error::msg)?;
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(
&config.localized_output_root,
&current_path,
config.published_release_id(),
)?;
let state = LocalizedVersionState {
state_version: LOCALIZED_VERSION_STATE_VERSION,
official_release_id: config.release_id.clone(),
current_release_id: Some(config.published_release_id().to_string()),
status: "localized".to_string(),
translation_workflow_status,
updated_unix_seconds: unix_seconds_now(),
};
write_file_atomic(
&state_path,
&serde_json::to_vec_pretty(&state)?,
STATE_FILE_MODE,
"汉化版本状态",
)
.map_err(anyhow::Error::msg)?;
let integrity = verify_published_localized_release(
&config.official_release_root,
&version_path,
&current_path,
&config.unzip_command,
)?;
Ok(LocalizedPatchReport {
version_path,
current_path,
state_path,
patch_manifest_path,
files: changed_files,
manifest,
integrity,
})
}
}
/// Reads the localized release state without following a symlink at the file
/// path. A missing state file means no localized release has been published.
pub fn read_localized_version_state(
localized_output_root: &Path,
) -> anyhow::Result<Option<LocalizedVersionState>> {
let path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE);
let Some(bytes) = read_file_no_symlink(&path, "汉化版本状态").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
let state: LocalizedVersionState = serde_json::from_slice(&bytes)?;
if state.state_version != LOCALIZED_VERSION_STATE_VERSION {
return Err(anyhow::anyhow!(
"不支持的汉化版本状态 schema{},当前版本=1",
state.state_version
));
}
Ok(Some(state))
}
/// Writes the localized version state atomically.
pub fn write_localized_version_state(
localized_output_root: &Path,
state: &LocalizedVersionState,
) -> anyhow::Result<PathBuf> {
ensure_safe_directory_path(localized_output_root, "汉化输出目录")
.map_err(anyhow::Error::msg)?;
let path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE);
write_file_atomic(
&path,
&serde_json::to_vec_pretty(state)?,
STATE_FILE_MODE,
"汉化版本状态",
)
.map_err(anyhow::Error::msg)?;
Ok(path)
}
/// Marks the current localized workflow as under manual proofreading without
/// changing the published localized release pointer.
pub fn mark_localized_manual_proofreading(
localized_output_root: &Path,
official_release_id: &str,
) -> anyhow::Result<LocalizedTranslationWorkflowReport> {
let mut state = read_localized_version_state(localized_output_root)?.unwrap_or_else(|| {
LocalizedVersionState {
state_version: LOCALIZED_VERSION_STATE_VERSION,
official_release_id: official_release_id.to_string(),
current_release_id: None,
status: "not_localized".to_string(),
translation_workflow_status: None,
updated_unix_seconds: 0,
}
});
if state.official_release_id != official_release_id {
return Err(anyhow::anyhow!(
"汉化状态 release={} 与当前官方 release={} 不一致;请先重新发布或指定匹配的官方 release",
state.official_release_id,
official_release_id
));
}
state.state_version = LOCALIZED_VERSION_STATE_VERSION;
state.translation_workflow_status =
Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING.to_string());
state.updated_unix_seconds = unix_seconds_now();
let state_path = write_localized_version_state(localized_output_root, &state)?;
Ok(LocalizedTranslationWorkflowReport {
command: "translation-proofread",
status: "updated",
localized_output_root: localized_output_root.to_path_buf(),
state_path,
official_release_id: state.official_release_id.clone(),
current_release_id: state.current_release_id.clone(),
localized_release_status: state.status.clone(),
translation_workflow_status: LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING.to_string(),
translation_workflow_status_code:
crate::ReleaseFlowStatusCode::TranslationManualProofreading.as_str(),
translation_workflow_label: LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL,
publish_allowed: state.status == "localized" && state.current_release_id.is_some(),
updated_unix_seconds: state.updated_unix_seconds,
})
}
/// Reads a localized patch manifest from a published version directory.
pub fn read_localized_patch_manifest_at(
version_path: &Path,
) -> anyhow::Result<Option<LocalizedPatchManifest>> {
let path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE);
let Some(bytes) =
read_file_no_symlink(&path, "汉化 patch manifest").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
let manifest: LocalizedPatchManifest = serde_json::from_slice(&bytes)?;
if manifest.manifest_version != LOCALIZED_PATCH_MANIFEST_VERSION {
return Err(anyhow::anyhow!(
"不支持的汉化 patch manifest schema{},当前版本={}",
manifest.manifest_version,
LOCALIZED_PATCH_MANIFEST_VERSION
));
}
Ok(Some(manifest))
}
impl LocalizedPatchOperation {
fn from_text_asset_patch(
patch: &TextAssetPatch,
metadata: Option<&LocalizedPatchOperationMetadata>,
) -> 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,
field_path: None,
expected_name: patch.expected_name.clone(),
replacement_bytes: patch.replacement.len() as u64,
replacement_blake3: blake3::hash(&patch.replacement).to_hex().to_string(),
text_unit_id: None,
source_text_blake3: None,
translation_provider: None,
provider_run_id: None,
translation_source_kind: None,
translation_memory_record_id: None,
review_status: None,
glossary_qa: None,
glossary_override: None,
},
metadata,
)
}
fn from_string_field_patch(
patch: &StringFieldPatch,
metadata: Option<&LocalizedPatchOperationMetadata>,
) -> 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,
field_path: Some(patch.field_path.clone()),
expected_name: None,
replacement_bytes: patch.replacement.len() as u64,
replacement_blake3: blake3::hash(patch.replacement.as_bytes())
.to_hex()
.to_string(),
text_unit_id: None,
source_text_blake3: None,
translation_provider: None,
provider_run_id: None,
translation_source_kind: None,
translation_memory_record_id: None,
review_status: None,
glossary_qa: None,
glossary_override: None,
},
metadata,
)
}
fn from_field_patch(
patch: &FieldPatch,
metadata: Option<&LocalizedPatchOperationMetadata>,
) -> anyhow::Result<Self> {
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,
field_path: Some(patch.field_path.clone()),
expected_name: None,
replacement_bytes: replacement.len() as u64,
replacement_blake3: blake3::hash(&replacement).to_hex().to_string(),
text_unit_id: None,
source_text_blake3: None,
translation_provider: None,
provider_run_id: None,
translation_source_kind: None,
translation_memory_record_id: None,
review_status: None,
glossary_qa: None,
glossary_override: None,
},
metadata,
))
}
fn with_metadata(
mut operation: Self,
metadata: Option<&LocalizedPatchOperationMetadata>,
) -> Self {
if let Some(metadata) = metadata {
operation.text_unit_id = Some(metadata.text_unit_id.clone());
operation.source_text_blake3 = Some(metadata.source_text_blake3.clone());
operation.translation_provider = metadata.translation_provider.clone();
operation.provider_run_id = metadata.provider_run_id.clone();
operation.translation_source_kind = metadata.translation_source_kind.clone();
operation.translation_memory_record_id = metadata.translation_memory_record_id.clone();
operation.review_status = Some(metadata.review_status.clone());
operation.glossary_qa = metadata.glossary_qa.clone();
operation.glossary_override = metadata.glossary_override.clone();
}
operation
}
}
fn default_patch_operation_kind() -> String {
"unityfs_text_asset".to_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,
field_path: None,
expected_name: None,
replacement_bytes: 0,
replacement_blake3: String::new(),
text_unit_id: None,
source_text_blake3: None,
translation_provider: None,
provider_run_id: None,
translation_source_kind: None,
translation_memory_record_id: None,
review_status: None,
glossary_qa: None,
glossary_override: None,
}
}
}
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!(
"缺少汉化 patch manifest{}",
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
)
})?;
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!(
"汉化 current 未指向发布版本:current={} version={}",
current_path.display(),
version_path.display()
));
}
Ok(integrity)
}
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 {
let relative = Path::new(&file.path);
let official_path = official_release_root.join(relative);
let localized_path = localized_release_root.join(relative);
ensure_path_within_root(official_release_root, &official_path)
.map_err(anyhow::Error::msg)?;
ensure_path_within_root(localized_release_root, &localized_path)
.map_err(anyhow::Error::msg)?;
ensure_safe_file_target(official_release_root, &official_path, "官方 patch 原文件")
.map_err(anyhow::Error::msg)?;
ensure_safe_file_target(localized_release_root, &localized_path, "汉化 patch 产物")
.map_err(anyhow::Error::msg)?;
let original = fs::read(&official_path)?;
let localized = fs::read(&localized_path)?;
let original_hash = blake3::hash(&original).to_hex().to_string();
let localized_hash = blake3::hash(&localized).to_hex().to_string();
if original_hash != file.original_blake3 || original.len() as u64 != file.original_bytes {
return Err(anyhow::anyhow!(
"汉化 manifest 原文件校验失败 {}:期望 hash={} bytes={},实际 hash={} bytes={}",
file.path,
file.original_blake3,
file.original_bytes,
original_hash,
original.len()
));
}
if localized_hash != file.localized_blake3 || localized.len() as u64 != file.localized_bytes
{
return Err(anyhow::anyhow!(
"汉化 manifest 产物校验失败 {}:期望 hash={} bytes={},实际 hash={} bytes={}",
file.path,
file.localized_blake3,
file.localized_bytes,
localized_hash,
localized.len()
));
}
if localized_hash == original_hash {
return Err(anyhow::anyhow!(
"汉化 manifest 文件未发生变化:{}",
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() {
return Err(anyhow::anyhow!(
"汉化 manifest file_count 不一致:声明 {},实际 {}",
manifest.file_count,
manifest.files.len()
));
}
if manifest.text_asset_operation_count != operation_count {
return Err(anyhow::anyhow!(
"汉化 manifest operation_count 不一致:声明 {},实际 {}",
manifest.text_asset_operation_count,
operation_count
));
}
Ok(LocalizedPatchIntegrity {
verified_changed_file_count: manifest.files.len(),
verified_text_asset_operation_count: operation_count,
current_points_to_release: false,
})
}
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)?)),
Ok(_) => Err(anyhow::anyhow!(
"汉化 current 已存在但不是 symlink{}",
current_path.display()
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
fn validate_previous_current_target(root: &Path, target: &Path) -> anyhow::Result<()> {
if release_id_from_current_target(target).is_none() {
return Err(anyhow::anyhow!(
"汉化 current 目标不是受支持的 versions/<release> 路径:{}",
target.display()
));
}
let target_path = root.join(target);
ensure_path_within_root(root, &target_path).map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(&target_path, "汉化 current 目标").map_err(anyhow::Error::msg)?;
if !target_path.is_dir() {
return Err(anyhow::anyhow!(
"汉化 current 目标 release 不存在:{}",
target_path.display()
));
}
Ok(())
}
fn current_points_to_version(current_path: &Path, version_path: &Path) -> anyhow::Result<bool> {
let metadata = fs::symlink_metadata(current_path)?;
if !metadata.file_type().is_symlink() {
return Ok(false);
}
Ok(fs::canonicalize(current_path)? == fs::canonicalize(version_path)?)
}
fn release_id_from_current_target(target: &Path) -> Option<String> {
let mut components = target.components();
match (components.next(), components.next(), components.next()) {
(
Some(std::path::Component::Normal(root)),
Some(std::path::Component::Normal(release_id)),
None,
) if root == LOCALIZED_VERSIONS_DIR => release_id.to_str().map(str::to_string),
_ => None,
}
}
fn rollback_failed_publish(
localized_output_root: &Path,
staging: &Path,
version_path: &Path,
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)?;
}
remove_owned_path(&localized_output_root.join(".current.tmp"))?;
remove_owned_path(&localized_output_root.join(".current.rollback.tmp"))?;
let failed_target = version_path
.file_name()
.map(|release_id| Path::new(LOCALIZED_VERSIONS_DIR).join(release_id));
if failed_target.as_ref().is_some_and(|target| {
fs::read_link(current_path)
.map(|current_target| current_target == *target)
.unwrap_or(false)
}) {
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(())
}
fn remove_owned_path(path: &Path) -> anyhow::Result<()> {
if let Ok(metadata) = fs::symlink_metadata(path) {
if metadata.file_type().is_symlink() || metadata.is_file() {
fs::remove_file(path)?;
} else if metadata.is_dir() {
fs::remove_dir_all(path)?;
}
}
Ok(())
}
#[cfg(unix)]
fn restore_current_symlink(
root: &Path,
current_path: &Path,
previous_current_target: Option<&PathBuf>,
) -> anyhow::Result<()> {
use std::os::unix::fs::symlink;
if let Some(previous_target) = previous_current_target {
let temporary = root.join(".current.rollback.tmp");
remove_owned_path(&temporary)?;
symlink(previous_target, &temporary)?;
fs::rename(temporary, current_path)?;
} else if fs::symlink_metadata(current_path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
fs::remove_file(current_path)?;
}
Ok(())
}
#[cfg(not(unix))]
fn restore_current_symlink(
_root: &Path,
_current_path: &Path,
_previous_current_target: Option<&PathBuf>,
) -> anyhow::Result<()> {
Ok(())
}
fn validate_config(config: &LocalizedPatchConfig) -> Result<(), String> {
let official = lexical_absolute(&config.official_release_root)?;
let localized = lexical_absolute(&config.localized_output_root)?;
if official == localized || official.starts_with(&localized) || localized.starts_with(&official)
{
return Err(format!(
"官方 release 与汉化输出目录不能相同或互相嵌套:官方={} 汉化={}",
official.display(),
localized.display()
));
}
for (label, release_id) in [
("官方", config.release_id.as_str()),
("汉化", config.published_release_id()),
] {
if release_id.is_empty()
|| release_id.contains('/')
|| release_id.contains('\\')
|| release_id == "."
|| release_id == ".."
{
return Err(format!("非法{label} release id{release_id}"));
}
}
ensure_safe_directory_path(&config.official_release_root, "官方 release")?;
ensure_safe_directory_path(&config.localized_output_root, "汉化输出目录")?;
Ok(())
}
fn copy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> {
let metadata = fs::symlink_metadata(source)?;
if metadata.file_type().is_symlink() {
return Err(anyhow::anyhow!(
"官方 release 不能包含 symlink: {}",
source.display()
));
}
if metadata.is_dir() {
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
copy_tree(&entry.path(), &destination.join(entry.file_name()))?;
}
} else if metadata.is_file() {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(source, destination)?;
} else {
return Err(anyhow::anyhow!(
"官方 release 中存在非普通文件: {}",
source.display()
));
}
Ok(())
}
fn remove_owned_staging(path: &Path) -> anyhow::Result<()> {
if let Ok(metadata) = fs::symlink_metadata(path) {
if metadata.file_type().is_symlink() {
return Err(anyhow::anyhow!(
"汉化 staging 不能是 symlink: {}",
path.display()
));
}
if metadata.is_dir() {
fs::remove_dir_all(path)?;
} else {
fs::remove_file(path)?;
}
}
Ok(())
}
#[cfg(unix)]
fn switch_current_symlink(root: &Path, current: &Path, release_id: &str) -> anyhow::Result<()> {
use std::os::unix::fs::symlink;
let temporary = root.join(".current.tmp");
if let Ok(metadata) = fs::symlink_metadata(&temporary) {
if metadata.file_type().is_symlink() || metadata.is_file() {
fs::remove_file(&temporary)?;
} else if metadata.is_dir() {
fs::remove_dir_all(&temporary)?;
}
}
symlink(
Path::new(LOCALIZED_VERSIONS_DIR).join(release_id),
&temporary,
)?;
fs::rename(temporary, current)?;
Ok(())
}
#[cfg(not(unix))]
fn switch_current_symlink(_root: &Path, _current: &Path, _release_id: &str) -> anyhow::Result<()> {
Err(anyhow::anyhow!(
"localized release publication requires a Unix symlink-capable platform"
))
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn default_patch_manifest_version() -> u32 {
LOCALIZED_PATCH_MANIFEST_VERSION
}
fn default_localized_version_state_version() -> u32 {
LOCALIZED_VERSION_STATE_VERSION
}
#[cfg(test)]
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";
let target = b"target";
let manifest = LocalizedPatchManifest {
manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION,
official_release_id: "official-v1".to_string(),
localized_release_id: "localized-v1".to_string(),
generated_unix_seconds: 123,
file_count: 1,
text_asset_operation_count: 1,
files: vec![LocalizedPatchFile {
path: "Bundles/file.bundle".to_string(),
original_blake3: blake3::hash(source).to_hex().to_string(),
localized_blake3: blake3::hash(target).to_hex().to_string(),
original_bytes: source.len() as u64,
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,
field_path: None,
expected_name: Some("Text".to_string()),
replacement_bytes: target.len() as u64,
replacement_blake3: blake3::hash(target).to_hex().to_string(),
text_unit_id: Some("unit-1".to_string()),
source_text_blake3: Some(blake3::hash(source).to_hex().to_string()),
translation_provider: Some("mock".to_string()),
provider_run_id: Some("mock:unit-1:attempt-1".to_string()),
translation_source_kind: Some("provider".to_string()),
translation_memory_record_id: None,
review_status: Some("provider_completed".to_string()),
glossary_qa: None,
glossary_override: None,
}],
}],
rollback: LocalizedPatchRollbackInfo {
previous_current_target: Some(PathBuf::from("versions/previous")),
remove_version_path: PathBuf::from("versions/localized-v1"),
},
};
let patch_manifest = manifest.to_patch_manifest();
assert_eq!(patch_manifest.version, bat_patch::PATCH_MANIFEST_VERSION);
assert_eq!(patch_manifest.patch_id, "localized-v1");
assert_eq!(patch_manifest.source_version, "official-v1");
assert_eq!(patch_manifest.target_version, "localized-v1");
assert_eq!(patch_manifest.files.len(), 1);
assert_eq!(
patch_manifest.files[0].patch_kind,
bat_patch::PatchKind::UnityFsTextAsset
);
assert_eq!(
patch_manifest.rollback.previous_current_target,
Some(PathBuf::from("versions/previous"))
);
assert_eq!(
patch_manifest.rollback.remove_target_path,
Some(PathBuf::from("versions/localized-v1"))
);
}
#[test]
fn manifest_operation_keeps_current_glossary_qa_provenance() {
let qa = bat_core::domain::GlossaryQaReport {
qa_identity: "gqa-v1-current".to_string(),
status: bat_core::domain::GlossaryQaStatus::Pass,
constraints: Vec::new(),
diagnostics: Vec::new(),
};
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(),
source_text_blake3: "source-hash".to_string(),
translation_provider: None,
provider_run_id: None,
translation_source_kind: Some("manual".to_string()),
translation_memory_record_id: None,
review_status: "manual_reviewed".to_string(),
glossary_qa: Some(qa.clone()),
glossary_override: None,
}),
});
let operation = input.manifest_operation().unwrap();
assert_eq!(
operation
.glossary_qa
.as_ref()
.map(|report| report.qa_identity.as_str()),
Some("gqa-v1-current")
);
}
#[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() {
let temp = TempDir::new().unwrap();
let official = temp.path().join("official-release");
let localized = temp.path().join("localized");
fs::create_dir_all(official.join("TableBundles")).unwrap();
fs::write(
official.join("TableBundles/TableCatalog.bytes"),
b"official",
)
.unwrap();
let report = LocalizedPatchService::new()
.publish(&LocalizedPatchConfig::new(
&official,
&localized,
"release-1",
Vec::new(),
))
.unwrap();
assert_eq!(
fs::read(report.version_path.join("TableBundles/TableCatalog.bytes")).unwrap(),
b"official"
);
assert_eq!(
fs::read_link(report.current_path).unwrap(),
PathBuf::from("versions/release-1")
);
let state: LocalizedVersionState =
serde_json::from_slice(&fs::read(report.state_path).unwrap()).unwrap();
assert_eq!(state.status, "localized");
assert_eq!(state.current_release_id.as_deref(), Some("release-1"));
assert_eq!(state.translation_workflow_status(), None);
assert!(report.patch_manifest_path.is_file());
assert_eq!(report.manifest.file_count, 0);
assert_eq!(report.integrity.verified_changed_file_count, 0);
assert!(report.integrity.current_points_to_release);
let manifest = read_localized_patch_manifest_at(&report.version_path)
.unwrap()
.unwrap();
assert_eq!(manifest.localized_release_id, "release-1");
assert_eq!(manifest.rollback.previous_current_target, None);
}
#[cfg(unix)]
#[test]
fn rollback_restores_previous_localized_release() {
let temp = TempDir::new().unwrap();
let official = temp.path().join("official-release");
let localized = temp.path().join("localized");
fs::create_dir_all(official.join("TableBundles")).unwrap();
fs::write(
official.join("TableBundles/TableCatalog.bytes"),
b"official",
)
.unwrap();
let service = LocalizedPatchService::new();
service
.publish(&LocalizedPatchConfig::new(
&official,
&localized,
"release-1",
Vec::new(),
))
.unwrap();
service
.publish(
&LocalizedPatchConfig::new(&official, &localized, "release-1", Vec::new())
.with_localized_release_id("release-2"),
)
.unwrap();
let report = service.rollback(&localized, Some("release-2")).unwrap();
assert_eq!(report.rolled_back_release_id, "release-2");
assert_eq!(report.restored_release_id.as_deref(), Some("release-1"));
assert!(!localized
.join(LOCALIZED_VERSIONS_DIR)
.join("release-2")
.exists());
assert_eq!(
fs::read_link(localized.join(LOCALIZED_CURRENT_LINK)).unwrap(),
PathBuf::from("versions/release-1")
);
let state = read_localized_version_state(&localized).unwrap().unwrap();
assert_eq!(state.status, "localized");
assert_eq!(state.current_release_id.as_deref(), Some("release-1"));
}
#[cfg(unix)]
#[test]
fn publish_rejects_invalid_existing_current_target() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let official = temp.path().join("official-release");
let localized = temp.path().join("localized");
fs::create_dir_all(official.join("TableBundles")).unwrap();
fs::write(
official.join("TableBundles/TableCatalog.bytes"),
b"official",
)
.unwrap();
fs::create_dir_all(localized.join(LOCALIZED_VERSIONS_DIR)).unwrap();
symlink(
temp.path().join("outside"),
localized.join(LOCALIZED_CURRENT_LINK),
)
.unwrap();
let error = LocalizedPatchService::new()
.publish(&LocalizedPatchConfig::new(
&official,
&localized,
"release-1",
Vec::new(),
))
.unwrap_err();
assert!(error.to_string().contains("current 目标不是受支持"));
}
#[test]
fn localized_state_reads_legacy_file_without_workflow_status() {
let temp = TempDir::new().unwrap();
let localized = temp.path().join("localized");
fs::create_dir_all(&localized).unwrap();
fs::write(
localized.join(LOCALIZED_VERSION_STATE_FILE),
br#"{
"state_version": 1,
"official_release_id": "release-1",
"current_release_id": "release-1",
"status": "localized",
"updated_unix_seconds": 123
}"#,
)
.unwrap();
let state = read_localized_version_state(&localized).unwrap().unwrap();
assert_eq!(state.status, "localized");
assert_eq!(state.translation_workflow_status(), None);
assert_eq!(state.translation_workflow_label(), None);
}
#[test]
fn manual_proofreading_marker_preserves_published_state() {
let temp = TempDir::new().unwrap();
let localized = temp.path().join("localized");
fs::create_dir_all(&localized).unwrap();
write_localized_version_state(
&localized,
&LocalizedVersionState {
state_version: LOCALIZED_VERSION_STATE_VERSION,
official_release_id: "release-1".to_string(),
current_release_id: Some("release-1-auto".to_string()),
status: "localized".to_string(),
translation_workflow_status: None,
updated_unix_seconds: 123,
},
)
.unwrap();
let report = mark_localized_manual_proofreading(&localized, "release-1").unwrap();
let state = read_localized_version_state(&localized).unwrap().unwrap();
assert_eq!(report.translation_workflow_label, "人工校对中");
assert_eq!(
state.translation_workflow_status(),
Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING)
);
assert_eq!(state.translation_workflow_label(), Some("人工校对中"));
assert_eq!(state.status, "localized");
assert_eq!(state.current_release_id.as_deref(), Some("release-1-auto"));
assert!(report.publish_allowed);
}
#[cfg(unix)]
#[test]
fn failed_patch_publish_cleans_staging_and_unpublished_version() {
let temp = TempDir::new().unwrap();
let official = temp.path().join("official-release");
let localized = temp.path().join("localized");
fs::create_dir_all(official.join("Bundles")).unwrap();
fs::write(official.join("Bundles/bad.bundle"), b"not-unityfs").unwrap();
let error = LocalizedPatchService::new()
.publish(&LocalizedPatchConfig::new(
&official,
&localized,
"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,
}],
))
.unwrap_err();
assert!(error.to_string().contains("Bundles/bad.bundle"));
assert!(!localized
.join(LOCALIZED_STAGING_DIR)
.join("release-1")
.exists());
assert!(!localized
.join(LOCALIZED_VERSIONS_DIR)
.join("release-1")
.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);
}
}