//! 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, /// TextAsset replacement inside the bundle. pub text_asset: TextAssetPatch, /// TextUnit/provider metadata recorded in the localized manifest. pub metadata: Option, } /// 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 field replacement inside the bundle. pub string_field: StringFieldPatch, /// TextUnit/provider metadata recorded in the localized manifest. pub metadata: Option, } /// 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, /// Semantic field replacement inside the bundle. pub field: FieldPatch, /// TextUnit/provider metadata recorded in the localized manifest. pub metadata: Option, } /// 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, /// Provider run ID that produced the text, if applicable. #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_run_id: Option, /// Source kind of the translation result. #[serde(default, skip_serializing_if = "Option::is_none")] pub translation_source_kind: Option, /// Trusted Translation Memory record used for the text, if applicable. #[serde(default, skip_serializing_if = "Option::is_none")] pub translation_memory_record_id: Option, /// 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, /// Explicit confirmation for a blocking Glossary deviation. #[serde(default, skip_serializing_if = "Option::is_none")] pub glossary_override: Option, } /// 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, /// 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, /// General UnityFS text/field operations to apply. pub operations: Vec, /// 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, localized_output_root: impl Into, release_id: impl Into, patches: Vec, ) -> 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) -> 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) -> 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, zip_command: impl Into, ) -> 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 { let mut operations = self .patches .iter() .cloned() .map(LocalizedPatchInput::TextAsset) .collect::>(); 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> { 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 { 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, /// 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, /// 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, /// 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, } /// 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, /// 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, /// Expected TextAsset name, when provided. #[serde(default, skip_serializing_if = "Option::is_none")] pub expected_name: Option, /// 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, /// BLAKE3 of the source text validated before publication. #[serde(default, skip_serializing_if = "Option::is_none")] pub source_text_blake3: Option, /// Translation provider that produced the text, if applicable. #[serde(default, skip_serializing_if = "Option::is_none")] pub translation_provider: Option, /// Provider run ID that produced the text, if applicable. #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_run_id: Option, /// Source kind of the translation result. #[serde(default, skip_serializing_if = "Option::is_none")] pub translation_source_kind: Option, /// Trusted Translation Memory record used for the text, if applicable. #[serde(default, skip_serializing_if = "Option::is_none")] pub translation_memory_record_id: Option, /// Review state used by the publication input. #[serde(default, skip_serializing_if = "Option::is_none")] pub review_status: Option, /// Glossary QA recomputed for this publication. #[serde(default, skip_serializing_if = "Option::is_none")] pub glossary_qa: Option, /// Human confirmation bound to the published Glossary QA identity. #[serde(default, skip_serializing_if = "Option::is_none")] pub glossary_override: Option, } /// 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, /// 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, /// 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, /// 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, /// Restored current symlink target, if a previous release existed. #[serde(skip_serializing_if = "Option::is_none")] pub restored_current_target: Option, /// 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 { 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(¤t_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, ¤t_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 { 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(¤t_release_id); ensure_safe_directory_path(&version_path, "当前汉化 release") .map_err(anyhow::Error::msg)?; if !current_points_to_version(¤t_path, &version_path)? { return Err(anyhow::anyhow!( "汉化 current 未指向当前状态 release:current={} 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(¤t_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, ¤t_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, ) -> anyhow::Result { 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::>::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, ¤t_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, ¤t_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> { 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 { 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 { 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> { 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 { 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 { 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 { 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::>(); 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, Vec)> { 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::>::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> { 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::>(); 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 = 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> { 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/ 路径:{}", 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 { 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 { 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, value: u32) { data.extend_from_slice(&value.to_be_bytes()); } fn push_u64_be(data: &mut Vec, value: u64) { data.extend_from_slice(&value.to_be_bytes()); } fn push_u64_le(data: &mut Vec, value: u64) { data.extend_from_slice(&value.to_le_bytes()); } fn push_u32_le(data: &mut Vec, value: u32) { data.extend_from_slice(&value.to_le_bytes()); } fn push_i16_le(data: &mut Vec, value: i16) { data.extend_from_slice(&value.to_le_bytes()); } fn align_vec(data: &mut Vec, alignment: usize) { let remainder = data.len() % alignment; if remainder != 0 { data.resize(data.len() + alignment - remainder, 0); } } fn push_c_string(data: &mut Vec, value: &str) { data.extend_from_slice(value.as_bytes()); data.push(0); } fn synthetic_serialized_text_asset(bytes: &[u8]) -> Vec { 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 { 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"), ¤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); } }