//! 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, UnitySerializedReplacementValue, UnitySerializedValue, }; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, OpenOptions}; #[cfg(unix)] use std::os::unix::io::AsRawFd; 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"; /// Published per-release distribution metadata with localized bytes. pub const LOCALIZED_DISTRIBUTION_MANIFEST_FILE: &str = "localized-distribution-manifest.json"; const LOCALIZED_TRANSACTION_FILE: &str = ".localized-transaction.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 = "人工校对中"; #[derive(Debug)] pub(crate) struct LocalizedOutputLock { file: std::fs::File, } impl Drop for LocalizedOutputLock { fn drop(&mut self) { #[cfg(unix)] unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN); } } } impl LocalizedOutputLock { fn acquire(root: &Path) -> anyhow::Result { ensure_safe_directory_path(root, "汉化输出目录").map_err(anyhow::Error::msg)?; fs::create_dir_all(root)?; ensure_safe_directory_path(root, "汉化输出目录").map_err(anyhow::Error::msg)?; let path = root.join(".localized-release.lock"); ensure_safe_file_target(root, &path, "汉化 release 锁").map_err(anyhow::Error::msg)?; let file = OpenOptions::new() .create(true) .truncate(false) .read(true) .write(true) .open(&path)?; #[cfg(unix)] { let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; if result != 0 { return Err(anyhow::anyhow!( "获取汉化 release 锁失败 {}:{}", path.display(), std::io::Error::last_os_error() )); } } Ok(Self { file }) } } pub(crate) fn acquire_localized_output_lock(root: &Path) -> anyhow::Result { LocalizedOutputLock::acquire(root) } #[derive(Debug, Clone, Serialize, Deserialize)] struct LocalizedReleaseTransaction { version: u32, operation: String, phase: String, release_id: String, version_path: PathBuf, staging_path: Option, previous_current_target: Option, current_target: Option, previous_state_bytes: Option>, new_state: Option, #[serde(default)] rollback_backup_path: Option, } impl LocalizedReleaseTransaction { fn publish( release_id: &str, version_path: PathBuf, staging_path: PathBuf, previous_current_target: Option, previous_state_bytes: Option>, ) -> Self { Self { version: 1, operation: "publish".to_string(), phase: "prepared".to_string(), release_id: release_id.to_string(), version_path, staging_path: Some(staging_path), previous_current_target, current_target: Some(Path::new(LOCALIZED_VERSIONS_DIR).join(release_id)), previous_state_bytes, new_state: None, rollback_backup_path: None, } } } /// 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, /// Unified manifest-driven publication input. pub manifest: Option, /// 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(), manifest: None, 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 a unified manifest-driven publication input. pub fn with_manifest(mut self, manifest: bat_patch::PatchManifest) -> Self { self.manifest = Some(manifest); 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, } /// Actual bytes metadata written alongside a published localized release. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalizedDistributionEntry { /// Official URL associated with this release-relative file. pub url: String, /// Release-relative destination. pub destination: String, /// Actual localized file size. pub bytes: u64, /// BLAKE3 of the actual localized file. pub blake3: String, } /// Cheap, trusted distribution index generated at localized publication time. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalizedDistributionManifest { /// Metadata schema version. pub version: u32, /// Official source release identity. pub official_release_id: String, /// Localized release identity. pub localized_release_id: String, /// Deterministic identity of the complete source official mapping. #[serde(default)] pub source_mapping_identity: String, /// Deterministic identity of this localized mapping and its source. #[serde(default)] pub localized_mapping_identity: String, /// Persisted destination-to-entry index for single-entry lookup. #[serde(default)] pub destination_index: BTreeMap, /// Actual metadata for every official manifest entry. pub entries: Vec, } 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, /// All operations applied to this file, including Binary/JSON/Text. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub 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, /// BLAKE3 of the bytes immediately before this operation. #[serde(default, skip_serializing_if = "Option::is_none")] pub source_blake3: Option, /// Size of the bytes immediately before this operation. #[serde(default, skip_serializing_if = "Option::is_none")] pub source_bytes: Option, /// 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, /// Replacement payload retained for generic manifest conversion. #[serde(default, skip_serializing_if = "Option::is_none")] pub replacement: Option>, /// Expected source semantic/string value. #[serde(default, skip_serializing_if = "Option::is_none")] pub expected_value: Option, /// Expected semantic value for a TypeTree field replacement. #[serde(default, skip_serializing_if = "Option::is_none")] pub replacement_value: Option, /// 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, } /// Read-only contract and artifact verification result for one localized /// release. Contract validity covers the manifest schema and release identity; /// artifact integrity additionally verifies recorded files and UnityFS/ZIP /// semantic replacements. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct LocalizedArtifactIntegrityReport { /// `valid`, `legacy`, `invalid` or `missing`. pub manifest_contract_status: String, /// `valid`, `invalid` or `unavailable`. pub artifact_integrity_status: String, /// Whether all read-only checks passed. pub verified: bool, /// Whether `current` points to this release. pub current_points_to_release: bool, /// Whether the manifest file could be read. pub manifest_available: bool, /// Whether manifest release IDs match the observed state. pub manifest_matches_release: bool, /// First diagnostic, retained for compact callers. pub error: Option, /// All diagnostics from the read-only inspection. pub diagnostics: Vec, } /// 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, /// Generic manifest used as the auditable publication input/output when /// this release was driven by the unified workflow. #[serde(default, skip_serializing_if = "Option::is_none")] pub patch_manifest: Option, /// Rollback information for this release. pub rollback: LocalizedPatchRollbackInfo, } impl LocalizedPatchManifest { /// Converts this localized manifest to the generic patch manifest model. pub fn to_patch_manifest(&self) -> anyhow::Result { if let Some(manifest) = &self.patch_manifest { return Ok(manifest.clone()); } let mut files = Vec::with_capacity(self.files.len()); for file in &self.files { let source_operations = if file.operations.is_empty() { file.text_asset_operations .iter() .map(|operation| generic_manifest_operation(operation, 0)) .collect::>>()? } else { file.operations.clone() }; let operations = source_operations .iter() .enumerate() .map(|(sequence, operation)| { let mut operation = operation.clone(); operation.sequence = sequence as u32; operation }) .collect::>(); let patch_kind = operations .iter() .map(bat_patch::PatchManifestOperation::patch_kind) .collect::>(); let patch_kind = match patch_kind.len() { 0 => { return Err(anyhow::anyhow!( "localized manifest file 没有可转换的 operation:{}", file.path )) } 1 => *patch_kind.first().expect("non-empty"), _ => bat_patch::PatchKind::Mixed, }; files.push(bat_patch::PatchManifestFile { path: PathBuf::from(&file.path), patch_kind, source_blake3: file.original_blake3.clone(), target_blake3: file.localized_blake3.clone(), source_size: file.original_bytes, target_size: file.localized_bytes, operations, }); } Ok(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, 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 { validate_config(config).map_err(anyhow::Error::msg)?; let _lock = LocalizedOutputLock::acquire(&config.localized_output_root)?; recover_localized_transaction(&config.localized_output_root)?; 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); 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 transaction = LocalizedReleaseTransaction::publish( &published_release_id, version_path.clone(), staging.clone(), previous_current_target.clone(), previous_state_bytes.clone(), ); write_localized_transaction(&config.localized_output_root, &transaction)?; let version_existed_before = version_path.exists(); match self.publish_inner(config, previous_current_target.clone()) { Ok(report) => { remove_localized_transaction(&config.localized_output_root)?; 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}" )); } remove_localized_transaction(&config.localized_output_root)?; 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 { let _lock = LocalizedOutputLock::acquire(localized_output_root)?; recover_localized_transaction(localized_output_root)?; 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() ) })?; verify_localized_release_files(&version_path, &manifest)?; verify_localized_distribution_manifest_at(None, &version_path, ¤t_release_id)?; 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() ) })?, ); verify_localized_release_files(&previous_path, restored_manifest.as_ref().unwrap())?; verify_localized_distribution_manifest_at( None, &previous_path, restored_release_id.as_deref().unwrap_or_default(), )?; } let previous_state_bytes = read_file_no_symlink(&state_path, "汉化版本状态") .map_err(anyhow::Error::msg)? .ok_or_else(|| anyhow::anyhow!("缺少汉化版本状态"))?; let restored_official_release_id = restored_manifest .as_ref() .map(|manifest| manifest.official_release_id.clone()) .unwrap_or_else(|| state.official_release_id.clone()); let previous_workflow_status = state.translation_workflow_status.clone(); let translation_workflow_status = if restored_official_release_id == state.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(), }; let transaction = LocalizedReleaseTransaction { version: 1, operation: "rollback".to_string(), phase: "prepared".to_string(), release_id: current_release_id.clone(), version_path: remove_version_path.clone(), staging_path: None, previous_current_target: Some( Path::new(LOCALIZED_VERSIONS_DIR).join(¤t_release_id), ), current_target: manifest.rollback.previous_current_target.clone(), previous_state_bytes: Some(previous_state_bytes), new_state: Some(new_state.clone()), rollback_backup_path: Some(localized_output_root.join(".rollback").join(format!( "{}.{}", current_release_id, std::process::id() ))), }; write_localized_transaction(localized_output_root, &transaction)?; let mutation_result = (|| -> anyhow::Result<()> { let backup_path = transaction .rollback_backup_path .as_deref() .ok_or_else(|| anyhow::anyhow!("localized rollback 缺少备份路径"))?; ensure_path_within_root(localized_output_root, backup_path) .map_err(anyhow::Error::msg)?; ensure_safe_directory_path( backup_path.parent().unwrap_or(localized_output_root), "localized rollback 备份目录", ) .map_err(anyhow::Error::msg)?; fs::create_dir_all(backup_path.parent().unwrap_or(localized_output_root))?; ensure_safe_directory_path( backup_path.parent().unwrap_or(localized_output_root), "localized rollback 备份目录", ) .map_err(anyhow::Error::msg)?; fs::rename(&remove_version_path, backup_path)?; update_localized_transaction_phase(localized_output_root, "version_staged")?; restore_current_symlink( localized_output_root, ¤t_path, manifest.rollback.previous_current_target.as_deref(), )?; update_localized_transaction_phase(localized_output_root, "current_switched")?; write_localized_version_state_unlocked(localized_output_root, &new_state)?; update_localized_transaction_phase(localized_output_root, "state_written")?; if let Some(previous_target) = manifest.rollback.previous_current_target.as_deref() { if !current_points_to_version( ¤t_path, &localized_output_root.join(previous_target), )? { return Err(anyhow::anyhow!("localized rollback current 最终校验失败")); } } else if fs::symlink_metadata(¤t_path).is_ok() { return Err(anyhow::anyhow!("localized rollback 应移除 current 指针")); } update_localized_transaction_phase(localized_output_root, "version_removed")?; remove_owned_path(backup_path)?; Ok(()) })(); if let Err(error) = mutation_result { let recovery = recover_localized_transaction(localized_output_root); return match recovery { Ok(()) => Err(error), Err(recovery_error) => Err(anyhow::anyhow!( "{error}; localized rollback recovery failed: {recovery_error}" )), }; } remove_localized_transaction(localized_output_root)?; 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 (changed_files, generic_manifest) = if let Some(manifest) = &config.manifest { if manifest.source_version != config.release_id { return Err(anyhow::anyhow!( "generic patch source version={} 与配置官方 release={} 不一致", manifest.source_version, config.release_id )); } if manifest.target_version != config.published_release_id() { return Err(anyhow::anyhow!( "generic patch target version={} 与配置 localized release={} 不一致", manifest.target_version, config.published_release_id() )); } let changed_files = apply_generic_manifest_to_staging( &config.official_release_root, &staging, manifest, &config.unzip_command, &config.zip_command, )?; (changed_files, Some(manifest.clone())) } else { 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, None, )? } else { let mut patched = original.clone(); let mut manifest_operations = Vec::with_capacity(operations.len()); for operation in operations { let mut manifest_operation = operation.manifest_operation()?; manifest_operation.source_blake3 = Some(blake3::hash(&patched).to_hex().to_string()); manifest_operation.source_bytes = Some(patched.len() as u64); patched = operation .apply(&patched) .map_err(|error| anyhow::anyhow!("{bundle_path}: {error}"))?; manifest_operations.push(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(); let generic_operations = manifest_operations .iter() .enumerate() .map(|(sequence, operation)| { generic_manifest_operation(operation, sequence as u32) }) .collect::>>()?; 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, operations: generic_operations, }); } (changed_files, None) }; let generic_manifest = match generic_manifest { Some(manifest) => manifest, None => generic_manifest_from_localized_files( config.release_id.as_str(), config.published_release_id(), &changed_files, previous_current_target.clone(), version_path.clone(), )?, }; 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(), patch_manifest: Some(generic_manifest).map(|mut manifest| { manifest.rollback = bat_patch::PatchRollback { previous_current_target: previous_current_target.clone(), remove_target_path: Some(version_path.clone()), }; manifest }), 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, )?; if let Some(distribution) = build_localized_distribution_manifest(&config.official_release_root, &staging, config)? { write_file_atomic( &staging.join(LOCALIZED_DISTRIBUTION_MANIFEST_FILE), &serde_json::to_vec_pretty(&distribution)?, STATE_FILE_MODE, "localized distribution manifest", ) .map_err(anyhow::Error::msg)?; } fs::create_dir_all(config.localized_output_root.join(LOCALIZED_VERSIONS_DIR))?; fs::rename(&staging, &version_path)?; update_localized_transaction_phase(&config.localized_output_root, "version_published")?; switch_current_symlink( &config.localized_output_root, ¤t_path, config.published_release_id(), )?; update_localized_transaction_phase(&config.localized_output_root, "current_switched")?; 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(), }; update_localized_transaction_state(&config.localized_output_root, &state)?; write_file_atomic( &state_path, &serde_json::to_vec_pretty(&state)?, STATE_FILE_MODE, "汉化版本状态", ) .map_err(anyhow::Error::msg)?; update_localized_transaction_phase(&config.localized_output_root, "state_written")?; let integrity = verify_published_localized_release( &config.official_release_root, &version_path, ¤t_path, &config.unzip_command, )?; update_localized_transaction_phase(&config.localized_output_root, "verified")?; Ok(LocalizedPatchReport { version_path, current_path, state_path, patch_manifest_path, files: changed_files, manifest, integrity, }) } } fn generic_manifest_from_localized_files( source_version: &str, target_version: &str, files: &[LocalizedPatchFile], previous_current_target: Option, remove_target_path: PathBuf, ) -> anyhow::Result { let files = files .iter() .map(|file| { if file.operations.is_empty() { return Err(anyhow::anyhow!( "localized file 没有可审计 generic operation:{}", file.path )); } let kinds = file .operations .iter() .map(bat_patch::PatchManifestOperation::patch_kind) .collect::>(); let patch_kind = if kinds.len() == 1 { *kinds.first().expect("non-empty") } else { bat_patch::PatchKind::Mixed }; Ok(bat_patch::PatchManifestFile { path: PathBuf::from(&file.path), patch_kind, source_blake3: file.original_blake3.clone(), target_blake3: file.localized_blake3.clone(), source_size: file.original_bytes, target_size: file.localized_bytes, operations: file.operations.clone(), }) }) .collect::>>()?; let manifest = bat_patch::PatchManifest { version: bat_patch::PATCH_MANIFEST_VERSION, patch_id: target_version.to_string(), source_version: source_version.to_string(), target_version: target_version.to_string(), files, rollback: bat_patch::PatchRollback { previous_current_target, remove_target_path: Some(remove_target_path), }, }; bat_patch::validate_patch_manifest(&manifest) .map_err(|error| anyhow::anyhow!("localized generic manifest 无效:{error}"))?; Ok(manifest) } fn apply_generic_manifest_to_staging( official_release_root: &Path, staging_root: &Path, manifest: &bat_patch::PatchManifest, unzip_command: &Path, zip_command: &Path, ) -> anyhow::Result> { bat_patch::validate_patch_manifest(manifest) .map_err(|error| anyhow::anyhow!("generic patch manifest 无效:{error}"))?; if manifest.files.is_empty() { return Err(anyhow::anyhow!("generic patch manifest 不能没有文件")); } let mut changed_files = Vec::with_capacity(manifest.files.len()); for file in &manifest.files { if file.operations.is_empty() { return Err(anyhow::anyhow!( "generic patch manifest 文件没有操作:{}", file.path.display() )); } if file.patch_kind != bat_patch::PatchKind::Mixed && file .operations .iter() .any(|operation| operation.patch_kind() != file.patch_kind) { return Err(anyhow::anyhow!( "generic patch manifest file kind 与 operation 不一致:{}", file.path.display() )); } let source_path = official_release_root.join(&file.path); let target_path = staging_root.join(&file.path); ensure_path_within_root(official_release_root, &source_path).map_err(anyhow::Error::msg)?; ensure_path_within_root(staging_root, &target_path).map_err(anyhow::Error::msg)?; ensure_safe_file_target(official_release_root, &source_path, "generic patch 原文件") .map_err(anyhow::Error::msg)?; ensure_safe_file_target(staging_root, &target_path, "generic patch staging 文件") .map_err(anyhow::Error::msg)?; let original = fs::read(&source_path)?; let source_hash = blake3::hash(&original).to_hex().to_string(); if source_hash != file.source_blake3 || original.len() as u64 != file.source_size { return Err(anyhow::anyhow!( "generic patch source identity mismatch {}: expected hash={} bytes={}, actual hash={} bytes={}", file.path.display(), file.source_blake3, file.source_size, source_hash, original.len() )); } let has_archive = file .operations .iter() .any(|operation| operation.archive_entry.is_some()); let has_direct = file .operations .iter() .any(|operation| operation.archive_entry.is_none()); if has_archive && has_direct { return Err(anyhow::anyhow!( "generic patch manifest 不能混合直接文件操作和 ZIP 内操作:{}", file.path.display() )); } let (patched, localized_operations) = if has_archive { let inputs = file .operations .iter() .map(generic_operation_to_localized_input) .collect::>>()?; let (patched, _) = rewrite_zip_bundle( &original, &inputs, unzip_command, zip_command, Some(&file.operations), )?; let localized_operations = file .operations .iter() .filter_map(|operation| { generic_operation_to_localized_operation(operation).transpose() }) .collect::>>()?; (patched, localized_operations) } else { let mut patched = original.clone(); for operation in &file.operations { patched = apply_generic_operation(&patched, operation)?; } let localized_operations = file .operations .iter() .filter_map(|operation| { generic_operation_to_localized_operation(operation).transpose() }) .collect::>>()?; (patched, localized_operations) }; if original == patched { return Err(anyhow::anyhow!( "generic patch manifest operation produced no change: {}", file.path.display() )); } let target_hash = blake3::hash(&patched).to_hex().to_string(); if target_hash != file.target_blake3 || patched.len() as u64 != file.target_size { return Err(anyhow::anyhow!( "generic patch target mismatch {}: expected hash={} bytes={}, actual hash={} bytes={}", file.path.display(), file.target_blake3, file.target_size, target_hash, patched.len() )); } write_file_atomic( &target_path, &patched, STATE_FILE_MODE, "generic patch staging 输出", ) .map_err(anyhow::Error::msg)?; changed_files.push(LocalizedPatchFile { path: file.path.to_string_lossy().into_owned(), original_blake3: source_hash, localized_blake3: target_hash, 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: localized_operations, operations: file.operations.clone(), }); } verify_generic_manifest_outputs(official_release_root, staging_root, manifest, unzip_command)?; Ok(changed_files) } fn apply_generic_operation( input: &[u8], operation: &bat_patch::PatchManifestOperation, ) -> anyhow::Result> { verify_generic_operation_source(input, operation)?; match &operation.payload { bat_patch::PatchManifestOperationPayload::Binary { patch } => { Ok(bat_patch::binary::apply_binary_patch(input, patch)?) } bat_patch::PatchManifestOperationPayload::Json { patch } => { let source = std::str::from_utf8(input) .map_err(|error| anyhow::anyhow!("JSON patch source 不是 UTF-8:{error}"))?; let patch = serde_json::to_string(patch)?; Ok(bat_patch::json::apply_json_patch(source, &patch)?.into_bytes()) } bat_patch::PatchManifestOperationPayload::Text { patch } => { let source = std::str::from_utf8(input) .map_err(|error| anyhow::anyhow!("Text patch source 不是 UTF-8:{error}"))?; Ok(bat_patch::text::apply_text_patch(source, patch)?.into_bytes()) } bat_patch::PatchManifestOperationPayload::UnityFsTextAsset { .. } | bat_patch::PatchManifestOperationPayload::UnityFsStringField { .. } | bat_patch::PatchManifestOperationPayload::UnityFsField { .. } => { Ok(generic_operation_to_localized_input(operation)?.apply(input)?) } } } fn verify_generic_operation_source( input: &[u8], operation: &bat_patch::PatchManifestOperation, ) -> anyhow::Result<()> { let Some(expected_hash) = operation.source_blake3.as_deref() else { if operation.source_size.is_some() { return Err(anyhow::anyhow!( "generic patch operation source precondition 缺少 hash" )); } return Ok(()); }; let expected_size = operation .source_size .ok_or_else(|| anyhow::anyhow!("generic patch operation source precondition 缺少 size"))?; let actual_hash = blake3::hash(input).to_hex().to_string(); if actual_hash != expected_hash || input.len() as u64 != expected_size { return Err(anyhow::anyhow!( "generic patch operation source precondition mismatch at sequence {}: expected hash={} bytes={}, actual hash={} bytes={}", operation.sequence, expected_hash, expected_size, actual_hash, input.len() )); } Ok(()) } fn generic_operation_to_localized_input( operation: &bat_patch::PatchManifestOperation, ) -> anyhow::Result { let archive_entry = operation.archive_entry.clone(); let input = match &operation.payload { bat_patch::PatchManifestOperationPayload::UnityFsTextAsset { serialized_file_path, path_id, expected_name, replacement, } => LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch { bundle_path: String::new(), archive_entry, text_asset: TextAssetPatch { serialized_file_path: serialized_file_path.clone(), path_id: *path_id, expected_name: expected_name.clone(), replacement: replacement.clone(), }, metadata: None, }), bat_patch::PatchManifestOperationPayload::UnityFsStringField { serialized_file_path, path_id, field_path, expected_value, replacement, } => LocalizedPatchInput::StringField(LocalizedStringFieldPatch { bundle_path: String::new(), archive_entry, string_field: StringFieldPatch { serialized_file_path: serialized_file_path.clone(), path_id: *path_id, field_path: field_path.clone(), expected_value: expected_value.clone(), replacement: replacement.clone(), }, metadata: None, }), bat_patch::PatchManifestOperationPayload::UnityFsField { serialized_file_path, path_id, field_path, expected_value, replacement, } => LocalizedPatchInput::Field(LocalizedFieldPatch { bundle_path: String::new(), archive_entry, field: FieldPatch { serialized_file_path: serialized_file_path.clone(), path_id: *path_id, field_path: field_path.clone(), expected_value: expected_value .as_ref() .map(|value| serde_json::from_value(value.clone())) .transpose()?, replacement: serde_json::from_value(replacement.clone())?, }, metadata: None, }), _ => { return Err(anyhow::anyhow!( "generic manifest operation kind 不能作为 UnityFS 操作" )) } }; Ok(input) } fn generic_operation_to_localized_operation( operation: &bat_patch::PatchManifestOperation, ) -> anyhow::Result> { let mut localized = match &operation.payload { bat_patch::PatchManifestOperationPayload::UnityFsTextAsset { serialized_file_path, path_id, expected_name, replacement, } => LocalizedPatchOperation { archive_entry: operation.archive_entry.clone(), patch_kind: "unityfs_text_asset".to_string(), source_blake3: operation.source_blake3.clone(), source_bytes: operation.source_size, serialized_file_path: serialized_file_path.clone(), path_id: *path_id, field_path: None, expected_name: expected_name.clone(), replacement_bytes: replacement.len() as u64, replacement_blake3: blake3::hash(replacement).to_hex().to_string(), replacement: Some(replacement.clone()), expected_value: None, replacement_value: None, ..LocalizedPatchOperation::default() }, bat_patch::PatchManifestOperationPayload::UnityFsStringField { serialized_file_path, path_id, field_path, expected_value, replacement, } => LocalizedPatchOperation { archive_entry: operation.archive_entry.clone(), patch_kind: "unityfs_string_field".to_string(), source_blake3: operation.source_blake3.clone(), source_bytes: operation.source_size, serialized_file_path: serialized_file_path.clone(), path_id: *path_id, field_path: Some(field_path.clone()), replacement_bytes: replacement.len() as u64, replacement_blake3: blake3::hash(replacement.as_bytes()).to_hex().to_string(), replacement: Some(replacement.as_bytes().to_vec()), expected_value: expected_value .as_ref() .map(|value| UnitySerializedReplacementValue::String(value.clone())), replacement_value: None, ..LocalizedPatchOperation::default() }, bat_patch::PatchManifestOperationPayload::UnityFsField { serialized_file_path, path_id, field_path, expected_value, replacement, } => { let replacement_value: UnitySerializedReplacementValue = serde_json::from_value(replacement.clone())?; let replacement_bytes = serde_json::to_vec(&replacement_value)?; let expected_value = expected_value .as_ref() .map(|value| serde_json::from_value(value.clone())) .transpose()?; LocalizedPatchOperation { archive_entry: operation.archive_entry.clone(), patch_kind: "unityfs_field".to_string(), source_blake3: operation.source_blake3.clone(), source_bytes: operation.source_size, serialized_file_path: serialized_file_path.clone(), path_id: *path_id, field_path: Some(field_path.clone()), replacement_bytes: replacement_bytes.len() as u64, replacement_blake3: blake3::hash(&replacement_bytes).to_hex().to_string(), replacement: None, expected_value, replacement_value: Some(replacement_value), ..LocalizedPatchOperation::default() } } _ => return Ok(None), }; apply_generic_provenance(&mut localized, operation.provenance.as_ref())?; Ok(Some(localized)) } fn apply_generic_provenance( operation: &mut LocalizedPatchOperation, provenance: Option<&bat_patch::PatchManifestProvenance>, ) -> anyhow::Result<()> { let Some(provenance) = provenance else { return Ok(()); }; operation.text_unit_id = provenance.text_unit_id.clone(); operation.source_text_blake3 = provenance.source_text_blake3.clone(); operation.translation_provider = provenance.translation_provider.clone(); operation.provider_run_id = provenance.provider_run_id.clone(); operation.translation_source_kind = provenance.translation_source_kind.clone(); operation.translation_memory_record_id = provenance.translation_memory_record_id.clone(); operation.review_status = provenance.review_status.clone(); operation.glossary_qa = provenance .glossary_qa .as_ref() .map(|value| serde_json::from_value(value.clone())) .transpose()?; operation.glossary_override = provenance .glossary_override .as_ref() .map(|value| serde_json::from_value(value.clone())) .transpose()?; Ok(()) } fn verify_generic_manifest_outputs( official_release_root: &Path, localized_release_root: &Path, manifest: &bat_patch::PatchManifest, unzip_command: &Path, ) -> anyhow::Result<()> { for file in &manifest.files { let official_path = official_release_root.join(&file.path); let localized_path = localized_release_root.join(&file.path); let original = fs::read(&official_path)?; let localized = fs::read(&localized_path)?; let file_metadata = bat_patch::PatchManifestFile { path: file.path.clone(), patch_kind: file.patch_kind, source_blake3: file.source_blake3.clone(), target_blake3: file.target_blake3.clone(), source_size: file.source_size, target_size: file.target_size, operations: Vec::new(), }; bat_patch::verify_patch_file_bytes(&original, &localized, &file_metadata)?; let has_unity_operation = file.operations.iter().any(|operation| { matches!( operation.payload, bat_patch::PatchManifestOperationPayload::UnityFsTextAsset { .. } | bat_patch::PatchManifestOperationPayload::UnityFsStringField { .. } | bat_patch::PatchManifestOperationPayload::UnityFsField { .. } ) }); if file .operations .iter() .all(|operation| operation.archive_entry.is_none()) && !has_unity_operation { let mut replayed = original.clone(); for operation in &file.operations { replayed = apply_generic_operation(&replayed, operation)?; } if replayed != localized { return Err(anyhow::anyhow!( "generic patch final replay 与 published bytes 不一致:{}", file.path.display() )); } } let operations = file .operations .iter() .filter_map(|operation| generic_operation_to_localized_operation(operation).transpose()) .collect::>>()?; let operation_refs = operations.iter().collect::>(); if file .operations .iter() .any(|operation| operation.archive_entry.is_some()) { verify_zip_operations(unzip_command, &official_path, &operation_refs, false)?; verify_zip_operations(unzip_command, &localized_path, &operation_refs, true)?; } else if !operation_refs.is_empty() { let parsed = bat_assetbundle::UnityFsParser::new().parse(&localized)?; for operation in &operation_refs { verify_archive_operation_replacement( &parsed, operation, file.path.to_string_lossy().as_ref(), )?; } } } Ok(()) } /// 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 { let _lock = LocalizedOutputLock::acquire(localized_output_root)?; recover_localized_transaction(localized_output_root)?; write_localized_version_state_unlocked(localized_output_root, state) } fn write_localized_version_state_unlocked( 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 _lock = LocalizedOutputLock::acquire(localized_output_root)?; recover_localized_transaction(localized_output_root)?; 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_unlocked(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(), source_blake3: None, source_bytes: None, 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(), replacement: Some(patch.replacement.clone()), expected_value: None, replacement_value: None, 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(), source_blake3: None, source_bytes: None, 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(), replacement: Some(patch.replacement.as_bytes().to_vec()), expected_value: patch .expected_value .as_ref() .map(|value| UnitySerializedReplacementValue::String(value.clone())), replacement_value: None, 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(), source_blake3: None, source_bytes: None, 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(), replacement: None, expected_value: patch.expected_value.clone(), replacement_value: Some(patch.replacement.clone()), 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() } fn generic_manifest_operation( operation: &LocalizedPatchOperation, sequence: u32, ) -> anyhow::Result { let payload = match operation.patch_kind.as_str() { "unityfs_text_asset" => bat_patch::PatchManifestOperationPayload::UnityFsTextAsset { serialized_file_path: operation.serialized_file_path.clone(), path_id: operation.path_id, expected_name: operation.expected_name.clone(), replacement: operation.replacement.clone().ok_or_else(|| { anyhow::anyhow!("localized TextAsset manifest 缺少 replacement") })?, }, "unityfs_string_field" => { bat_patch::PatchManifestOperationPayload::UnityFsStringField { serialized_file_path: operation.serialized_file_path.clone(), path_id: operation.path_id, field_path: operation.field_path.clone().ok_or_else(|| { anyhow::anyhow!("localized string operation 缺少 field_path") })?, expected_value: operation.expected_value.as_ref().and_then( |value| match value { UnitySerializedReplacementValue::String(value) => Some(value.clone()), _ => None, }, ), replacement: String::from_utf8(operation.replacement.clone().ok_or_else( || anyhow::anyhow!("localized string manifest 缺少 replacement"), )?)?, } } "unityfs_field" => bat_patch::PatchManifestOperationPayload::UnityFsField { serialized_file_path: operation.serialized_file_path.clone(), path_id: operation.path_id, field_path: operation.field_path.clone().ok_or_else(|| { anyhow::anyhow!("localized semantic operation 缺少 field_path") })?, expected_value: operation .expected_value .as_ref() .map(serde_json::to_value) .transpose()?, replacement: serde_json::to_value( operation.replacement_value.as_ref().ok_or_else(|| { anyhow::anyhow!("localized semantic operation 缺少 replacement_value") })?, )?, }, other => { return Err(anyhow::anyhow!( "localized operation 不是受支持的 generic patch kind:{other}" )) } }; Ok(bat_patch::PatchManifestOperation { sequence, source_blake3: operation.source_blake3.clone(), source_size: operation.source_bytes, archive_entry: operation.archive_entry.clone(), payload, provenance: localized_provenance(operation), }) } fn localized_provenance( operation: &LocalizedPatchOperation, ) -> Option { let provenance = bat_patch::PatchManifestProvenance { text_unit_id: operation.text_unit_id.clone(), source_text_blake3: operation.source_text_blake3.clone(), translation_provider: operation.translation_provider.clone(), provider_run_id: operation.provider_run_id.clone(), translation_source_kind: operation.translation_source_kind.clone(), translation_memory_record_id: operation.translation_memory_record_id.clone(), review_status: operation.review_status.clone(), glossary_qa: operation .glossary_qa .as_ref() .and_then(|value| serde_json::to_value(value).ok()), glossary_override: operation .glossary_override .as_ref() .and_then(|value| serde_json::to_value(value).ok()), }; [ provenance.text_unit_id.as_ref(), provenance.source_text_blake3.as_ref(), provenance.translation_provider.as_ref(), provenance.provider_run_id.as_ref(), provenance.translation_source_kind.as_ref(), provenance.translation_memory_record_id.as_ref(), provenance.review_status.as_ref(), ] .iter() .any(Option::is_some) .then_some(provenance) } impl Default for LocalizedPatchOperation { fn default() -> Self { Self { archive_entry: None, patch_kind: default_patch_operation_kind(), source_blake3: None, source_bytes: None, serialized_file_path: String::new(), path_id: 0, field_path: None, expected_name: None, replacement_bytes: 0, replacement_blake3: String::new(), replacement: None, expected_value: None, replacement_value: None, 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, )?; verify_localized_distribution_manifest_at( Some(official_release_root), version_path, &manifest.localized_release_id, )?; 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() )); } let state_path = current_path .parent() .ok_or_else(|| anyhow::anyhow!("localized current 缺少输出根目录"))? .join(LOCALIZED_VERSION_STATE_FILE); let state = read_localized_version_state( current_path .parent() .ok_or_else(|| anyhow::anyhow!("localized current 缺少输出根目录"))?, )? .ok_or_else(|| anyhow::anyhow!("缺少 localized version state:{}", state_path.display()))?; if state.current_release_id.as_deref() != Some(manifest.localized_release_id.as_str()) { return Err(anyhow::anyhow!( "localized version state 与发布 manifest 不一致:expected={} actual={:?}", manifest.localized_release_id, state.current_release_id )); } Ok(integrity) } fn build_localized_distribution_manifest( official_release_root: &Path, staging_root: &Path, config: &LocalizedPatchConfig, ) -> anyhow::Result> { let Some(official_manifest) = crate::official_download::read_download_manifest_at(official_release_root) .map_err(anyhow::Error::msg)? else { return Ok(None); }; let source_mapping_identity = crate::official_download::official_distribution_mapping_identity(&official_manifest); let mut entries = Vec::with_capacity(official_manifest.entries.len()); for entry in official_manifest.entries.values() { let path = staging_root.join(&entry.destination); ensure_path_within_root(staging_root, &path).map_err(anyhow::Error::msg)?; ensure_safe_file_target(staging_root, &path, "localized distribution 文件") .map_err(anyhow::Error::msg)?; let bytes = fs::read(&path)?; entries.push(LocalizedDistributionEntry { url: entry.url.clone(), destination: entry.destination.clone(), bytes: bytes.len() as u64, blake3: blake3::hash(&bytes).to_hex().to_string(), }); } let destination_index = entries .iter() .enumerate() .map(|(index, entry)| (entry.destination.clone(), index)) .collect::>(); if destination_index.len() != entries.len() { return Err(anyhow::anyhow!( "official distribution manifest 存在重复 destination" )); } let localized_mapping_identity = localized_distribution_mapping_identity(&source_mapping_identity, &entries); Ok(Some(LocalizedDistributionManifest { version: 1, official_release_id: config.release_id.clone(), localized_release_id: config.published_release_id().to_string(), source_mapping_identity, localized_mapping_identity, destination_index, entries, })) } fn verify_localized_release_files( version_path: &Path, manifest: &LocalizedPatchManifest, ) -> anyhow::Result<()> { ensure_safe_directory_path(version_path, "localized release").map_err(anyhow::Error::msg)?; for file in &manifest.files { let path = version_path.join(&file.path); ensure_path_within_root(version_path, &path).map_err(anyhow::Error::msg)?; ensure_safe_file_target(version_path, &path, "localized release 文件") .map_err(anyhow::Error::msg)?; let bytes = fs::read(&path)?; let actual = blake3::hash(&bytes).to_hex().to_string(); if bytes.len() as u64 != file.localized_bytes || actual != file.localized_blake3 { return Err(anyhow::anyhow!( "localized release 文件完整性失败 {}:expected bytes={} blake3={} actual bytes={} blake3={}", file.path, file.localized_bytes, file.localized_blake3, bytes.len(), actual )); } } Ok(()) } fn verify_localized_distribution_manifest_at( official_release_root: Option<&Path>, version_path: &Path, localized_release_id: &str, ) -> anyhow::Result<()> { let path = version_path.join(LOCALIZED_DISTRIBUTION_MANIFEST_FILE); let Some(bytes) = read_file_no_symlink(&path, "localized distribution manifest") .map_err(anyhow::Error::msg)? else { // 旧 localized release 可能在 distribution manifest 引入前发布; // 保留兼容 rollback,新的 publish 仍会在有 official manifest 时生成它。 return Ok(()); }; let manifest: LocalizedDistributionManifest = serde_json::from_slice(&bytes)?; if manifest.version != 1 || manifest.localized_release_id != localized_release_id { return Err(anyhow::anyhow!( "localized distribution manifest identity 不一致:expected={} actual={}", localized_release_id, manifest.localized_release_id )); } let source_mapping_identity = if let Some(official_release_root) = official_release_root { let official_manifest = crate::official_download::read_download_manifest_at(official_release_root) .map_err(anyhow::Error::msg)? .ok_or_else(|| { anyhow::anyhow!("localized distribution 缺少 source official manifest") })?; let official_anchor = crate::official_download::verify_official_distribution_publication_at( official_release_root, &manifest.official_release_id, ) .map_err(anyhow::Error::msg)? .ok_or_else(|| { anyhow::anyhow!("localized distribution 缺少 official publication anchor") })?; let source_mapping_identity = official_anchor.mapping_identity.clone(); let expected_destination_index = crate::official_download::official_distribution_destination_index(&official_manifest) .map_err(anyhow::Error::msg)?; if manifest.source_mapping_identity != source_mapping_identity || official_manifest.distribution_mapping_identity.as_deref() != Some(source_mapping_identity.as_str()) || official_manifest.destination_index != expected_destination_index { return Err(anyhow::anyhow!( "localized distribution source mapping/index 不一致:expected={} actual={} official={:?}", source_mapping_identity, manifest.source_mapping_identity, official_manifest.distribution_mapping_identity )); } if !localized_distribution_entries_match_official(&manifest, &official_manifest) { return Err(anyhow::anyhow!( "localized distribution manifest 与 source official mapping 不一致" )); } source_mapping_identity } else { manifest.source_mapping_identity.clone() }; if !manifest.source_mapping_identity.is_empty() { let localized_mapping_identity = localized_distribution_mapping_identity(&source_mapping_identity, &manifest.entries); if manifest.localized_mapping_identity != localized_mapping_identity { return Err(anyhow::anyhow!( "localized distribution mapping identity 不一致:expected={} actual={}", localized_mapping_identity, manifest.localized_mapping_identity )); } if manifest.destination_index.len() != manifest.entries.len() || manifest .destination_index .iter() .any(|(destination, index)| { manifest .entries .get(*index) .is_none_or(|entry| entry.destination != *destination) }) { return Err(anyhow::anyhow!( "localized distribution destination index 不一致" )); } } let mut destinations = BTreeSet::new(); for entry in &manifest.entries { if !destinations.insert(entry.destination.as_str()) { return Err(anyhow::anyhow!( "localized distribution manifest 存在重复 destination:{}", entry.destination )); } let file_path = version_path.join(&entry.destination); ensure_path_within_root(version_path, &file_path).map_err(anyhow::Error::msg)?; ensure_safe_file_target(version_path, &file_path, "localized distribution 文件") .map_err(anyhow::Error::msg)?; let file_bytes = fs::read(&file_path)?; let actual = blake3::hash(&file_bytes).to_hex().to_string(); if file_bytes.len() as u64 != entry.bytes || actual != entry.blake3 { return Err(anyhow::anyhow!( "localized distribution 文件完整性失败 {}:expected bytes={} blake3={} actual bytes={} blake3={}", entry.destination, entry.bytes, entry.blake3, file_bytes.len(), actual )); } } Ok(()) } pub(crate) fn verify_localized_distribution_manifest_for_status( official_release_root: &Path, version_path: &Path, localized_release_id: &str, ) -> anyhow::Result<()> { verify_localized_distribution_manifest_at( Some(official_release_root), version_path, localized_release_id, ) } fn localized_distribution_entries_match_official( localized: &LocalizedDistributionManifest, official: &crate::official_download::OfficialDownloadManifest, ) -> bool { if localized.entries.len() != official.entries.len() { return false; } let mut localized_by_destination = BTreeMap::new(); for entry in &localized.entries { if localized_by_destination .insert(entry.destination.as_str(), entry.url.as_str()) .is_some() { return false; } } official.entries.values().all(|entry| { localized_by_destination.get(entry.destination.as_str()) == Some(&entry.url.as_str()) }) } fn localized_distribution_mapping_identity( source_mapping_identity: &str, entries: &[LocalizedDistributionEntry], ) -> String { let mut ordered = entries.iter().collect::>(); ordered.sort_by(|left, right| { left.destination .cmp(&right.destination) .then_with(|| left.url.cmp(&right.url)) .then_with(|| left.bytes.cmp(&right.bytes)) .then_with(|| left.blake3.cmp(&right.blake3)) }); let mut hasher = blake3::Hasher::new(); hasher.update(b"localized-distribution-mapping-v1"); update_distribution_identity_string(&mut hasher, source_mapping_identity); hasher.update(&(ordered.len() as u64).to_be_bytes()); for entry in ordered { update_distribution_identity_string(&mut hasher, &entry.destination); update_distribution_identity_string(&mut hasher, &entry.url); hasher.update(&entry.bytes.to_be_bytes()); update_distribution_identity_string(&mut hasher, &entry.blake3); } format!("ldm-v1-{}", hasher.finalize().to_hex()) } fn update_distribution_identity_string(hasher: &mut blake3::Hasher, value: &str) { hasher.update(&(value.len() as u64).to_be_bytes()); hasher.update(value.as_bytes()); } fn write_localized_transaction( localized_output_root: &Path, transaction: &LocalizedReleaseTransaction, ) -> anyhow::Result<()> { let path = localized_output_root.join(LOCALIZED_TRANSACTION_FILE); write_file_atomic( &path, &serde_json::to_vec_pretty(transaction)?, STATE_FILE_MODE, "localized release transaction", ) .map_err(anyhow::Error::msg) } fn update_localized_transaction_phase( localized_output_root: &Path, phase: &str, ) -> anyhow::Result<()> { let path = localized_output_root.join(LOCALIZED_TRANSACTION_FILE); let Some(bytes) = read_file_no_symlink(&path, "localized release transaction").map_err(anyhow::Error::msg)? else { return Err(anyhow::anyhow!( "localized release transaction 丢失:{}", path.display() )); }; let mut transaction: LocalizedReleaseTransaction = serde_json::from_slice(&bytes)?; transaction.phase = phase.to_string(); write_localized_transaction(localized_output_root, &transaction) } fn update_localized_transaction_state( localized_output_root: &Path, state: &LocalizedVersionState, ) -> anyhow::Result<()> { let path = localized_output_root.join(LOCALIZED_TRANSACTION_FILE); let Some(bytes) = read_file_no_symlink(&path, "localized release transaction").map_err(anyhow::Error::msg)? else { return Err(anyhow::anyhow!( "localized release transaction 丢失:{}", path.display() )); }; let mut transaction: LocalizedReleaseTransaction = serde_json::from_slice(&bytes)?; transaction.new_state = Some(state.clone()); write_localized_transaction(localized_output_root, &transaction) } fn remove_localized_transaction(localized_output_root: &Path) -> anyhow::Result<()> { let path = localized_output_root.join(LOCALIZED_TRANSACTION_FILE); match fs::symlink_metadata(&path) { Ok(metadata) if metadata.file_type().is_symlink() => { Err(anyhow::anyhow!("localized transaction 不能是 symlink")) } Ok(_) => { fs::remove_file(path)?; Ok(()) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => Err(error.into()), } } fn recover_localized_transaction(localized_output_root: &Path) -> anyhow::Result<()> { let path = localized_output_root.join(LOCALIZED_TRANSACTION_FILE); let Some(bytes) = read_file_no_symlink(&path, "localized release transaction").map_err(anyhow::Error::msg)? else { return Ok(()); }; let transaction: LocalizedReleaseTransaction = serde_json::from_slice(&bytes)?; if transaction.version != 1 { return Err(anyhow::anyhow!( "不支持的 localized transaction schema:{}", transaction.version )); } ensure_path_within_root(localized_output_root, &transaction.version_path) .map_err(anyhow::Error::msg)?; ensure_safe_directory_path(&transaction.version_path, "localized transaction release") .map_err(anyhow::Error::msg)?; let current_path = localized_output_root.join(LOCALIZED_CURRENT_LINK); let current_matches = match transaction.current_target.as_deref() { Some(target) => current_path .read_link() .map(|current| current == target) .unwrap_or(false), None => matches!( fs::symlink_metadata(¤t_path), Err(error) if error.kind() == std::io::ErrorKind::NotFound ), }; let state_matches = transaction.new_state.as_ref().is_some_and(|expected| { read_localized_version_state(localized_output_root) .ok() .flatten() .as_ref() == Some(expected) }); let publish_committed = transaction.operation == "publish" && transaction.phase == "verified" && transaction.version_path.is_dir() && current_matches && state_matches; let rollback_committed = transaction.operation == "rollback" && transaction.phase == "version_removed" && current_matches && state_matches; if publish_committed { if let Some(staging) = transaction.staging_path.as_deref() { remove_owned_path(staging)?; } } else if rollback_committed { remove_owned_path(&transaction.version_path)?; if let Some(backup) = transaction.rollback_backup_path.as_deref() { remove_owned_path(backup)?; } } else { if let Some(target) = transaction.previous_current_target.as_deref() { let target_path = localized_output_root.join(target); ensure_path_within_root(localized_output_root, &target_path) .map_err(anyhow::Error::msg)?; restore_current_symlink(localized_output_root, ¤t_path, Some(target))?; } else if transaction.operation == "publish" { restore_current_symlink(localized_output_root, ¤t_path, None)?; } if transaction.operation == "publish" { if let Some(staging) = transaction.staging_path.as_deref() { remove_owned_path(staging)?; } remove_owned_path(&transaction.version_path)?; } if transaction.operation == "rollback" { if let Some(backup) = transaction.rollback_backup_path.as_deref() { if fs::symlink_metadata(backup).is_ok() { remove_owned_path(&transaction.version_path)?; fs::rename(backup, &transaction.version_path)?; } } } if let Some(previous_state) = transaction.previous_state_bytes.as_deref() { write_file_atomic( &localized_output_root.join(LOCALIZED_VERSION_STATE_FILE), previous_state, STATE_FILE_MODE, "恢复 localized version state", ) .map_err(anyhow::Error::msg)?; } else { remove_owned_path(&localized_output_root.join(LOCALIZED_VERSION_STATE_FILE))?; } } remove_localized_transaction(localized_output_root) } pub(crate) fn recover_localized_output_transaction(root: &Path) -> anyhow::Result<()> { recover_localized_transaction(root) } pub(crate) fn localized_output_transaction_pending(root: &Path) -> anyhow::Result { Ok(read_file_no_symlink( &root.join(LOCALIZED_TRANSACTION_FILE), "localized release transaction", ) .map_err(anyhow::Error::msg)? .is_some()) } /// Inspects one localized release without changing state, staging, current or /// any repair target. pub fn inspect_localized_release_artifact( official_release_root: &Path, localized_output_root: &Path, localized_release_id: &str, expected_official_release_id: &str, unzip_command: &Path, ) -> LocalizedArtifactIntegrityReport { inspect_localized_release_artifact_inner( official_release_root, localized_output_root, localized_release_id, expected_official_release_id, unzip_command, true, ) } /// Inspects a historical localized release without requiring the channel /// `current` pointer to select it. pub fn inspect_localized_release_artifact_at( official_release_root: &Path, localized_output_root: &Path, localized_release_id: &str, expected_official_release_id: &str, unzip_command: &Path, ) -> LocalizedArtifactIntegrityReport { inspect_localized_release_artifact_inner( official_release_root, localized_output_root, localized_release_id, expected_official_release_id, unzip_command, false, ) } fn is_safe_release_id(value: &str) -> bool { !value.is_empty() && value != "." && value != ".." && !value.contains('/') && !value.contains('\\') && !value.contains(':') && !value.contains('\0') } fn inspect_localized_release_artifact_inner( official_release_root: &Path, localized_output_root: &Path, localized_release_id: &str, expected_official_release_id: &str, unzip_command: &Path, require_current_pointer: bool, ) -> LocalizedArtifactIntegrityReport { let mut diagnostics = Vec::new(); if !is_safe_release_id(localized_release_id) { diagnostics.push(format!( "localized release identity 不安全:{localized_release_id}" )); } if !is_safe_release_id(expected_official_release_id) { diagnostics.push(format!( "official release identity 不安全:{expected_official_release_id}" )); } if !diagnostics.is_empty() { return LocalizedArtifactIntegrityReport { manifest_contract_status: "invalid".to_string(), artifact_integrity_status: "invalid".to_string(), verified: false, current_points_to_release: false, manifest_available: false, manifest_matches_release: false, error: diagnostics.first().cloned(), diagnostics, }; } let version_path = localized_output_root .join(LOCALIZED_VERSIONS_DIR) .join(localized_release_id); let current_path = localized_output_root.join(LOCALIZED_CURRENT_LINK); let current_points_to_release = current_points_to_version(¤t_path, &version_path).unwrap_or(false); let candidate_exists = fs::symlink_metadata(&version_path) .map(|metadata| metadata.is_dir()) .unwrap_or(false); if !candidate_exists { diagnostics.push(format!( "localized release 目录不存在:{}", version_path.display() )); return LocalizedArtifactIntegrityReport { manifest_contract_status: "missing".to_string(), artifact_integrity_status: "unavailable".to_string(), verified: false, current_points_to_release, manifest_available: false, manifest_matches_release: false, error: diagnostics.first().cloned(), diagnostics, }; } if require_current_pointer && !current_points_to_release { diagnostics.push(format!( "localized current 未指向 release:current={} version={}", current_path.display(), version_path.display() )); } if let Err(error) = ensure_safe_directory_path(&version_path, "汉化 release") { diagnostics.push(error.to_string()); } let manifest_available = fs::symlink_metadata(version_path.join(LOCALIZED_PATCH_MANIFEST_FILE)) .map(|metadata| !metadata.file_type().is_symlink()) .unwrap_or(false); let manifest = match read_localized_patch_manifest_at(&version_path) { Ok(Some(manifest)) => manifest, Ok(None) => { diagnostics.push(format!( "缺少汉化 patch manifest:{}", version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display() )); return LocalizedArtifactIntegrityReport { manifest_contract_status: "missing".to_string(), artifact_integrity_status: "invalid".to_string(), verified: false, current_points_to_release, manifest_available, manifest_matches_release: false, error: diagnostics.first().cloned(), diagnostics, }; } Err(error) => { diagnostics.push(error.to_string()); return LocalizedArtifactIntegrityReport { manifest_contract_status: "invalid".to_string(), artifact_integrity_status: "invalid".to_string(), verified: false, current_points_to_release, manifest_available, manifest_matches_release: false, error: diagnostics.first().cloned(), diagnostics, }; } }; let wrapper_matches_release = manifest.official_release_id == expected_official_release_id && manifest.localized_release_id == localized_release_id; if !wrapper_matches_release { diagnostics.push(format!( "localized manifest identity 不匹配:official={} localized={}", manifest.official_release_id, manifest.localized_release_id )); } let generic_matches_wrapper = manifest.patch_manifest.as_ref().is_none_or(|generic| { generic.source_version == manifest.official_release_id && generic.target_version == manifest.localized_release_id }); if !generic_matches_wrapper { diagnostics.push( "generic manifest source_version/target_version 与 localized wrapper 不一致" .to_string(), ); } let manifest_matches_release = wrapper_matches_release && generic_matches_wrapper; let manifest_contract_status = if let Some(generic) = manifest.patch_manifest.as_ref() { match bat_patch::validate_patch_manifest(generic) { Ok(()) if manifest_matches_release => "valid", Ok(()) => "invalid", Err(error) => { diagnostics.push(format!("generic manifest schema 无效:{error}")); "invalid" } } } else { "legacy" }; if manifest_contract_status == "invalid" { return LocalizedArtifactIntegrityReport { manifest_contract_status: manifest_contract_status.to_string(), artifact_integrity_status: "invalid".to_string(), verified: false, current_points_to_release, manifest_available, manifest_matches_release, error: diagnostics.first().cloned(), diagnostics, }; } if let Err(error) = verify_patch_manifest_files( official_release_root, &version_path, &manifest, unzip_command, ) { diagnostics.push(error.to_string()); } if manifest.patch_manifest.is_some() { match crate::official_download::read_download_manifest_at(official_release_root) { Ok(Some(download_manifest)) => { if let Err(error) = verify_localized_resource_root( official_release_root, &version_path, &manifest, &download_manifest, ) { diagnostics.push(error.to_string()); } } Ok(None) => diagnostics.push(format!( "缺少官方 download manifest,无法完成 generic release 全量完整性检查:{}", official_release_root.display() )), Err(error) => diagnostics.push(error), } } let verified = diagnostics.is_empty() && (!require_current_pointer || current_points_to_release) && manifest_matches_release; LocalizedArtifactIntegrityReport { manifest_contract_status: manifest_contract_status.to_string(), artifact_integrity_status: if verified { "valid".to_string() } else { "invalid".to_string() }, verified, current_points_to_release, manifest_available, manifest_matches_release, error: diagnostics.first().cloned(), diagnostics, } } fn verify_localized_resource_root( official_release_root: &Path, localized_release_root: &Path, localized_manifest: &LocalizedPatchManifest, download_manifest: &crate::official_download::OfficialDownloadManifest, ) -> anyhow::Result<()> { let changed_paths = localized_manifest .files .iter() .map(|file| file.path.as_str()) .collect::>(); for entry in download_manifest.entries.values() { let official_path = official_release_root.join(&entry.destination); let localized_path = localized_release_root.join(&entry.destination); 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, "官方 release 文件") .map_err(anyhow::Error::msg)?; ensure_safe_file_target(localized_release_root, &localized_path, "汉化 release 文件") .map_err(anyhow::Error::msg)?; let official = fs::read(&official_path)?; if official.len() as u64 != entry.bytes || blake3::hash(&official).to_hex().to_string() != entry.blake3 { return Err(anyhow::anyhow!( "官方 source 文件完整性失败:{}", entry.destination )); } let localized = fs::read(&localized_path)?; if changed_paths.contains(entry.destination.as_str()) { continue; } if localized.len() as u64 != entry.bytes || blake3::hash(&localized).to_hex().to_string() != entry.blake3 { return Err(anyhow::anyhow!( "汉化 release 未变更文件完整性失败:{}", entry.destination )); } } Ok(()) } 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 )); } if let Some(generic_manifest) = manifest.patch_manifest.as_ref() { bat_patch::validate_patch_manifest(generic_manifest) .map_err(|error| anyhow::anyhow!("嵌套 generic patch manifest 无效:{error}"))?; if generic_manifest.source_version != manifest.official_release_id { return Err(anyhow::anyhow!( "generic manifest source version={} 与 localized manifest official release={} 不一致", generic_manifest.source_version, manifest.official_release_id )); } if generic_manifest.target_version != manifest.localized_release_id { return Err(anyhow::anyhow!( "generic manifest target version={} 与 localized manifest release={} 不一致", generic_manifest.target_version, manifest.localized_release_id )); } if generic_manifest.files.len() != manifest.files.len() || generic_manifest .files .iter() .zip(&manifest.files) .any(|(generic, localized)| { generic.path.to_string_lossy() != localized.path || generic.source_blake3 != localized.original_blake3 || generic.target_blake3 != localized.localized_blake3 || generic.source_size != localized.original_bytes || generic.target_size != localized.localized_bytes || generic.operations != localized.operations }) { return Err(anyhow::anyhow!( "generic manifest 与 localized manifest 文件/操作记录不一致" )); } verify_generic_manifest_outputs( official_release_root, localized_release_root, generic_manifest, unzip_command, )?; } 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, generic_operations: Option<&[bat_patch::PatchManifestOperation]>, ) -> 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 (index, operation) in operations.iter().enumerate() { 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)? }; if let Some(generic_operations) = generic_operations { let generic_operation = generic_operations.get(index).ok_or_else(|| { anyhow::anyhow!("generic patch manifest ZIP operation 数量不一致") })?; verify_generic_operation_source(¤t, generic_operation)?; } 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); let mut manifest_operation = operation.manifest_operation()?; manifest_operation.source_blake3 = Some(blake3::hash(¤t).to_hex().to_string()); manifest_operation.source_bytes = Some(current.len() as u64); manifest_operations.push(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 )); } } } "unityfs_field" => { let field_path = operation.field_path.as_deref().ok_or_else(|| { anyhow::anyhow!( "ZIP 内 UnityFS semantic 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 semantic 字段不存在:{}!{}:{}:{}", archive_entry, operation.serialized_file_path, operation.path_id, field_path ) })?; let expected = operation.replacement_value.as_ref().ok_or_else(|| { anyhow::anyhow!( "ZIP 内 UnityFS semantic operation 缺少 replacement_value,不能完成最终语义校验:{}!{}:{}:{}", archive_entry, operation.serialized_file_path, operation.path_id, field_path ) })?; if !expected.matches_serialized_value(value) { return Err(anyhow::anyhow!( "ZIP 内 UnityFS semantic replacement 校验失败:{}!{}:{}:{}", archive_entry, operation.serialized_file_path, operation.path_id, field_path )); } return Ok(()); } other => { return Err(anyhow::anyhow!( "不支持的 ZIP UnityFS manifest operation:{other}" )); } }; 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 => { let release_id = release_id.to_str()?; is_safe_release_id(release_id).then_some(release_id.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.map(PathBuf::as_path), )?; } 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<&Path>, ) -> 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<&Path>, ) -> 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.contains(':') || release_id.contains('\0') || 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::*; #[cfg(unix)] use std::sync::mpsc; #[cfg(unix)] use std::time::Duration; 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_i32_le(data: &mut Vec, value: i32) { data.extend_from_slice(&value.to_le_bytes()); } #[cfg(unix)] #[test] fn localized_output_lock_serializes_independent_handles() { let temp = TempDir::new().unwrap(); let first = LocalizedOutputLock::acquire(temp.path()).unwrap(); let (sender, receiver) = mpsc::channel(); let root = temp.path().to_path_buf(); let worker = std::thread::spawn(move || { let second = LocalizedOutputLock::acquire(&root).unwrap(); sender.send(()).unwrap(); drop(second); }); assert!(receiver.recv_timeout(Duration::from_millis(50)).is_err()); drop(first); receiver.recv_timeout(Duration::from_secs(1)).unwrap(); worker.join().unwrap(); } #[cfg(unix)] #[test] fn interrupted_publish_transaction_is_recovered_before_next_mutation() { use std::os::unix::fs::symlink; let temp = TempDir::new().unwrap(); let root = temp.path().join("localized"); let old = root.join(LOCALIZED_VERSIONS_DIR).join("old"); let new = root.join(LOCALIZED_VERSIONS_DIR).join("new"); let staging = root.join(LOCALIZED_STAGING_DIR).join("new"); fs::create_dir_all(&old).unwrap(); fs::create_dir_all(&new).unwrap(); fs::create_dir_all(&staging).unwrap(); symlink( Path::new(LOCALIZED_VERSIONS_DIR).join("new"), root.join(LOCALIZED_CURRENT_LINK), ) .unwrap(); let old_state = LocalizedVersionState { state_version: LOCALIZED_VERSION_STATE_VERSION, official_release_id: "official-old".to_string(), current_release_id: Some("old".to_string()), status: "localized".to_string(), translation_workflow_status: None, updated_unix_seconds: 1, }; let old_state_bytes = serde_json::to_vec_pretty(&old_state).unwrap(); write_file_atomic( &root.join(LOCALIZED_VERSION_STATE_FILE), &old_state_bytes, STATE_FILE_MODE, "test state", ) .unwrap(); write_localized_transaction( &root, &LocalizedReleaseTransaction::publish( "new", new.clone(), staging.clone(), Some(PathBuf::from("versions/old")), Some(old_state_bytes), ), ) .unwrap(); write_localized_version_state(&root, &old_state).unwrap(); assert_eq!( fs::read_link(root.join(LOCALIZED_CURRENT_LINK)).unwrap(), PathBuf::from("versions/old") ); assert!(!new.exists()); assert!(!staging.exists()); assert!(!localized_output_transaction_pending(&root).unwrap()); } #[cfg(unix)] #[test] fn publish_recovery_requires_verified_phase_before_roll_forward() { use std::os::unix::fs::symlink; for (phase, new_is_current, should_keep_new) in [ ("prepared", false, false), ("version_published", false, false), ("current_switched", true, false), ("state_written", true, false), ("verification_failed", true, false), ("verification_succeeded", true, false), ("verified", true, true), ] { let temp = TempDir::new().unwrap(); let root = temp.path().join("localized"); let old = root.join(LOCALIZED_VERSIONS_DIR).join("old"); let new = root.join(LOCALIZED_VERSIONS_DIR).join("new"); let staging = root.join(LOCALIZED_STAGING_DIR).join("new"); fs::create_dir_all(&old).unwrap(); fs::create_dir_all(&new).unwrap(); fs::create_dir_all(&staging).unwrap(); symlink( Path::new(LOCALIZED_VERSIONS_DIR).join(if new_is_current { "new" } else { "old" }), root.join(LOCALIZED_CURRENT_LINK), ) .unwrap(); let old_state = LocalizedVersionState { state_version: LOCALIZED_VERSION_STATE_VERSION, official_release_id: "official-old".to_string(), current_release_id: Some("old".to_string()), status: "localized".to_string(), translation_workflow_status: None, updated_unix_seconds: 1, }; let new_state = LocalizedVersionState { current_release_id: Some("new".to_string()), updated_unix_seconds: 2, ..old_state.clone() }; let old_state_bytes = serde_json::to_vec_pretty(&old_state).unwrap(); let state_bytes = if new_is_current { serde_json::to_vec_pretty(&new_state).unwrap() } else { old_state_bytes.clone() }; write_file_atomic( &root.join(LOCALIZED_VERSION_STATE_FILE), &state_bytes, STATE_FILE_MODE, "test state", ) .unwrap(); let mut transaction = LocalizedReleaseTransaction::publish( "new", new.clone(), staging.clone(), Some(PathBuf::from("versions/old")), Some(old_state_bytes), ); transaction.phase = phase.to_string(); transaction.new_state = Some(new_state.clone()); write_localized_transaction(&root, &transaction).unwrap(); recover_localized_transaction(&root).unwrap(); if should_keep_new { assert_eq!( fs::read_link(root.join(LOCALIZED_CURRENT_LINK)).unwrap(), PathBuf::from("versions/new") ); assert_eq!( read_localized_version_state(&root).unwrap(), Some(new_state) ); assert!(new.exists()); } else { assert_eq!( fs::read_link(root.join(LOCALIZED_CURRENT_LINK)).unwrap(), PathBuf::from("versions/old") ); assert_eq!( read_localized_version_state(&root).unwrap(), Some(old_state) ); assert!(!new.exists()); } assert!(!staging.exists()); assert!(!localized_output_transaction_pending(&root).unwrap()); } } 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_serialized_monobehaviour() -> Vec { let mut object_data = Vec::new(); push_u32_le(&mut object_data, 5); object_data.extend_from_slice(b"hello"); align_vec(&mut object_data, 4); let mut strings = Vec::new(); let root_type = strings.len(); strings.extend_from_slice(b"MonoBehaviour\0"); let root_name = strings.len(); strings.push(0); let field_type = strings.len(); strings.extend_from_slice(b"string\0"); let field_name = strings.len(); strings.extend_from_slice(b"message\0"); let mut metadata = Vec::new(); metadata.extend_from_slice(b"2021.3.56f2\0"); push_i32_le(&mut metadata, 19); metadata.push(1); push_i32_le(&mut metadata, 1); push_i32_le(&mut metadata, 114); metadata.push(0); push_i16_le(&mut metadata, 0); metadata.extend_from_slice(&[0; 16]); metadata.extend_from_slice(&[0; 16]); push_i32_le(&mut metadata, 2); push_i32_le(&mut metadata, strings.len() as i32); for (level, type_offset, name_offset) in [ (0u8, root_type as i32, root_name as i32), (1u8, field_type as i32, field_name as i32), ] { metadata.extend_from_slice(&1u16.to_le_bytes()); metadata.push(level); metadata.push(0); push_i32_le(&mut metadata, type_offset); push_i32_le(&mut metadata, name_offset); push_i32_le(&mut metadata, -1); push_i32_le(&mut metadata, 0); push_i32_le(&mut metadata, 0); push_u64_le(&mut metadata, 0); } metadata.extend_from_slice(&strings); push_i32_le(&mut metadata, 0); push_i32_le(&mut metadata, 1); align_vec(&mut metadata, 4); metadata.extend_from_slice(&1i64.to_le_bytes()); push_u64_le(&mut metadata, 0); push_u32_le(&mut metadata, object_data.len() as u32); push_i32_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.push(0); file.extend_from_slice(&[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(), source_blake3: Some(blake3::hash(source).to_hex().to_string()), source_bytes: Some(source.len() as u64), 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(), replacement: Some(target.to_vec()), expected_value: None, replacement_value: None, 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, }], operations: Vec::new(), }], patch_manifest: 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().unwrap(); 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 generic_manifest_publishes_binary_json_and_text_files() { let temp = TempDir::new().unwrap(); let official = temp.path().join("official-release"); let target = temp.path().join("target-release"); let localized = temp.path().join("localized"); fs::create_dir_all(&official).unwrap(); fs::create_dir_all(&target).unwrap(); let binary_source = b"binary-before"; let binary_target = b"binary-after-longer"; let json_source = br#"{"value":0}"#; let json_target = br#"{"value":1}"#; let text_source = "old text\n"; let text_target = "translated text with a different length\n"; fs::write(official.join("data.bin"), binary_source).unwrap(); fs::write(target.join("data.bin"), binary_target).unwrap(); fs::write(official.join("data.json"), json_source).unwrap(); fs::write(target.join("data.json"), json_target).unwrap(); fs::write(official.join("text.txt"), text_source).unwrap(); fs::write(target.join("text.txt"), text_target).unwrap(); let mut official_manifest = crate::OfficialDownloadManifest { version: 1, entries: [ ( "https://example.invalid/data.bin".to_string(), "data.bin", binary_source.as_slice(), ), ( "https://example.invalid/data.json".to_string(), "data.json", json_source.as_slice(), ), ( "https://example.invalid/text.txt".to_string(), "text.txt", text_source.as_bytes(), ), ] .into_iter() .map(|(url, destination, bytes)| { ( url.clone(), crate::OfficialDownloadManifestEntry { url, destination: destination.to_string(), bytes: bytes.len() as u64, blake3: blake3::hash(bytes).to_hex().to_string(), }, ) }) .collect(), destination_index: BTreeMap::new(), distribution_mapping_identity: None, }; official_manifest.distribution_mapping_identity = Some( crate::official_distribution_mapping_identity(&official_manifest), ); official_manifest.destination_index = crate::official_download::official_distribution_destination_index(&official_manifest) .unwrap(); fs::write( official.join("official-download-manifest.json"), serde_json::to_vec(&official_manifest).unwrap(), ) .unwrap(); crate::official_download::write_official_distribution_publication_anchor_at( &official, "official-v1", ) .unwrap(); let manifest = bat_patch::build_patch_manifest( &official, &target, "localized-v1", "official-v1", "localized-v1", vec![ bat_patch::PatchManifestBuildFile { path: PathBuf::from("data.bin"), patch_kind: bat_patch::PatchKind::Binary, operations: vec![bat_patch::PatchManifestOperation { sequence: 0, source_blake3: None, source_size: None, archive_entry: None, payload: bat_patch::PatchManifestOperationPayload::Binary { patch: bat_patch::binary::diff(binary_source, binary_target), }, provenance: None, }], }, bat_patch::PatchManifestBuildFile { path: PathBuf::from("data.json"), patch_kind: bat_patch::PatchKind::Json, operations: vec![bat_patch::PatchManifestOperation { sequence: 0, source_blake3: None, source_size: None, archive_entry: None, payload: bat_patch::PatchManifestOperationPayload::Json { patch: serde_json::json!([{ "op": "replace", "path": "/value", "value": 1 }]), }, provenance: None, }], }, bat_patch::PatchManifestBuildFile { path: PathBuf::from("text.txt"), patch_kind: bat_patch::PatchKind::Text, operations: vec![bat_patch::PatchManifestOperation { sequence: 0, source_blake3: None, source_size: None, archive_entry: None, payload: bat_patch::PatchManifestOperationPayload::Text { patch: bat_patch::text::diff(text_source, text_target), }, provenance: None, }], }, ], bat_patch::PatchRollback { previous_current_target: None, remove_target_path: Some(PathBuf::from("versions/localized-v1")), }, ) .unwrap(); let report = LocalizedPatchService::new() .publish( &LocalizedPatchConfig::new(&official, &localized, "official-v1", Vec::new()) .with_manifest(manifest) .with_localized_release_id("localized-v1"), ) .unwrap(); assert_eq!( fs::read(report.version_path.join("data.bin")).unwrap(), binary_target ); assert_eq!( fs::read(report.version_path.join("data.json")).unwrap(), json_target ); assert_eq!( fs::read_to_string(report.version_path.join("text.txt")).unwrap(), text_target ); let generic = report.manifest.patch_manifest.as_ref().unwrap(); assert_eq!(generic.files.len(), 3); assert_eq!( generic .files .iter() .flat_map(|file| file.operations.iter()) .map(|operation| operation.patch_kind()) .collect::>(), vec![ bat_patch::PatchKind::Binary, bat_patch::PatchKind::Json, bat_patch::PatchKind::Text ] ); assert!(report.integrity.current_points_to_release); let distribution: LocalizedDistributionManifest = serde_json::from_slice( &fs::read( report .version_path .join(LOCALIZED_DISTRIBUTION_MANIFEST_FILE), ) .unwrap(), ) .unwrap(); assert_eq!(distribution.entries.len(), 3); assert_eq!( distribution.source_mapping_identity, crate::official_distribution_mapping_identity(&official_manifest) ); assert_eq!(distribution.destination_index.len(), 3); assert!(!distribution.localized_mapping_identity.is_empty()); for (path, expected) in [ ("data.bin", binary_target.as_slice()), ("data.json", json_target.as_slice()), ("text.txt", text_target.as_bytes()), ] { let entry = distribution .entries .iter() .find(|entry| entry.destination == path) .unwrap(); assert_eq!(entry.bytes, expected.len() as u64); assert_eq!(entry.blake3, blake3::hash(expected).to_hex().to_string()); } let mut tampered_official = official_manifest; tampered_official .entries .get_mut("https://example.invalid/data.json") .unwrap() .bytes += 1; fs::write( official.join("official-download-manifest.json"), serde_json::to_vec(&tampered_official).unwrap(), ) .unwrap(); assert!(verify_localized_distribution_manifest_for_status( &official, &report.version_path, "localized-v1" ) .is_err()); } #[cfg(unix)] #[test] fn generic_manifest_failure_removes_only_unpublished_staging() { let temp = TempDir::new().unwrap(); let official = temp.path().join("official-release"); let localized = temp.path().join("localized"); fs::create_dir_all(&official).unwrap(); fs::write(official.join("data.bin"), b"official").unwrap(); let manifest = bat_patch::PatchManifest { version: bat_patch::PATCH_MANIFEST_VERSION, patch_id: "localized-v1".to_string(), source_version: "wrong-official".to_string(), target_version: "localized-v1".to_string(), files: Vec::new(), rollback: bat_patch::PatchRollback { previous_current_target: None, remove_target_path: Some(PathBuf::from("versions/localized-v1")), }, }; let error = LocalizedPatchService::new() .publish( &LocalizedPatchConfig::new(&official, &localized, "official-v1", Vec::new()) .with_manifest(manifest) .with_localized_release_id("localized-v1"), ) .unwrap_err(); assert!(error.to_string().contains("source version")); assert!(!localized .join(LOCALIZED_STAGING_DIR) .join("localized-v1") .exists()); assert!(!localized .join(LOCALIZED_VERSIONS_DIR) .join("localized-v1") .exists()); assert!(!localized.join(LOCALIZED_CURRENT_LINK).exists()); } #[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 final_zip_semantic_verification_checks_location_field_and_value() { let temp = TempDir::new().unwrap(); let zip_source = temp.path().join("zip-source"); fs::create_dir_all(zip_source.join("nested")).unwrap(); let original_bundle = synthetic_unityfs(&synthetic_serialized_monobehaviour()); let patched_bundle = patch_unityfs_field( &original_bundle, &FieldPatch { serialized_file_path: "CAB-asset".to_string(), path_id: 1, field_path: "message".to_string(), expected_value: Some(UnitySerializedReplacementValue::String("hello".to_string())), replacement: UnitySerializedReplacementValue::String("你好".to_string()), }, ) .unwrap(); fs::write(zip_source.join("nested/CAB-story"), patched_bundle).unwrap(); let archive_path = temp.path().join("localized.zip"); let status = Command::new("zip") .current_dir(&zip_source) .arg("-q") .arg("-r") .arg(&archive_path) .arg(".") .status() .unwrap(); assert!(status.success()); let mut operation = LocalizedPatchOperation::from_field_patch( &FieldPatch { serialized_file_path: "CAB-asset".to_string(), path_id: 1, field_path: "message".to_string(), expected_value: Some(UnitySerializedReplacementValue::String("hello".to_string())), replacement: UnitySerializedReplacementValue::String("你好".to_string()), }, None, ) .unwrap(); operation.archive_entry = Some("nested/CAB-story".to_string()); let operations = vec![&operation]; verify_zip_operations(Path::new("unzip"), &archive_path, &operations, true).unwrap(); let mut wrong_serialized_file = operation.clone(); wrong_serialized_file.serialized_file_path = "CAB-other".to_string(); let error = verify_zip_operations( Path::new("unzip"), &archive_path, &[&wrong_serialized_file], true, ) .unwrap_err(); assert!(error.to_string().contains("serialized file")); let mut wrong_field_path = operation.clone(); wrong_field_path.field_path = Some("wrong".to_string()); let error = verify_zip_operations( Path::new("unzip"), &archive_path, &[&wrong_field_path], true, ) .unwrap_err(); assert!(error.to_string().contains("字段不存在")); let mut wrong_replacement = operation; wrong_replacement.replacement_value = Some(UnitySerializedReplacementValue::String("错误".to_string())); let error = verify_zip_operations( Path::new("unzip"), &archive_path, &[&wrong_replacement], true, ) .unwrap_err(); assert!(error.to_string().contains("replacement 校验失败")); } #[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); } #[test] fn artifact_inspection_rejects_unsafe_release_identity_before_reading_paths() { let temp = TempDir::new().unwrap(); let report = inspect_localized_release_artifact_at( &temp.path().join("official"), &temp.path().join("localized"), "../outside", "official-1", Path::new("unzip"), ); assert_eq!(report.manifest_contract_status, "invalid"); assert_eq!(report.artifact_integrity_status, "invalid"); assert!(!report.verified); assert!(report .diagnostics .iter() .any(|diagnostic| diagnostic.contains("identity 不安全"))); } #[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); } }