Files
BlueArchiveToolkit/infrastructure/src/localized_patch.rs
T
nyaKazuha 37d49c9793
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
fix(release): 完成官方发布身份与 legacy CAS 归属保护
2026-09-13 15:34:42 +08:00

4823 lines
190 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Localized release publishing for verified UnityFS text patches.
use bat_assetbundle::{
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch, Parser,
StringFieldPatch, TextAssetPatch, UnitySerializedField, 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<Self> {
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> {
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<PathBuf>,
previous_current_target: Option<PathBuf>,
current_target: Option<PathBuf>,
previous_state_bytes: Option<Vec<u8>>,
new_state: Option<LocalizedVersionState>,
#[serde(default)]
rollback_backup_path: Option<PathBuf>,
}
impl LocalizedReleaseTransaction {
fn publish(
release_id: &str,
version_path: PathBuf,
staging_path: PathBuf,
previous_current_target: Option<PathBuf>,
previous_state_bytes: Option<Vec<u8>>,
) -> 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<String>,
/// TextAsset replacement inside the bundle.
pub text_asset: TextAssetPatch,
/// TextUnit/provider metadata recorded in the localized manifest.
pub metadata: Option<LocalizedPatchOperationMetadata>,
}
/// One TypeTree string patch operation against a bundle in an official release.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalizedStringFieldPatch {
/// Relative path of the UnityFS bundle under the official release.
pub bundle_path: String,
/// Relative path of the UnityFS bundle inside an outer ZIP archive.
pub archive_entry: Option<String>,
/// String field replacement inside the bundle.
pub string_field: StringFieldPatch,
/// TextUnit/provider metadata recorded in the localized manifest.
pub metadata: Option<LocalizedPatchOperationMetadata>,
}
/// One semantic TypeTree patch operation against a bundle in an official release.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalizedFieldPatch {
/// Relative path of the UnityFS bundle under the official release.
pub bundle_path: String,
/// Relative path of the UnityFS bundle inside an outer ZIP archive.
pub archive_entry: Option<String>,
/// Semantic field replacement inside the bundle.
pub field: FieldPatch,
/// TextUnit/provider metadata recorded in the localized manifest.
pub metadata: Option<LocalizedPatchOperationMetadata>,
}
/// Supported localized UnityFS patch operation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalizedPatchInput {
/// Replace a TextAsset payload.
TextAsset(LocalizedTextAssetPatch),
/// Replace a TypeTree string field.
StringField(LocalizedStringFieldPatch),
/// Replace a supported semantic TypeTree field.
Field(LocalizedFieldPatch),
}
/// Trace metadata for one localized patch operation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchOperationMetadata {
/// Stable TextUnit ID from the official TextUnit index.
pub text_unit_id: String,
/// BLAKE3 of the source text validated before publication.
pub source_text_blake3: String,
/// Translation provider that produced the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_provider: Option<String>,
/// Provider run ID that produced the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_run_id: Option<String>,
/// Source kind of the translation result.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_source_kind: Option<String>,
/// Trusted Translation Memory record used for the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_memory_record_id: Option<String>,
/// Review state used by the publication input.
pub review_status: String,
/// Deterministic Glossary QA recorded for this translation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<bat_core::domain::GlossaryQaReport>,
/// Explicit confirmation for a blocking Glossary deviation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<bat_core::domain::GlossaryOverride>,
}
/// Configuration for one localized release publication.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalizedPatchConfig {
/// Immutable, verified official release root.
pub official_release_root: PathBuf,
/// Separate localized publication root.
pub localized_output_root: PathBuf,
/// Version identifier shared with the official release.
pub release_id: String,
/// Optional distinct localized release ID. When omitted, `release_id` is
/// used for backward-compatible publication paths.
pub localized_release_id: Option<String>,
/// Allow publishing a new localized release even when the source release
/// already has a localized current release. The caller should normally
/// provide a distinct localized release ID.
pub force: bool,
/// Patch operations to apply.
pub patches: Vec<LocalizedTextAssetPatch>,
/// General UnityFS text/field operations to apply.
pub operations: Vec<LocalizedPatchInput>,
/// Unified manifest-driven publication input.
pub manifest: Option<bat_patch::PatchManifest>,
/// Unzip executable used for ZIP-inner bundle publication.
pub unzip_command: PathBuf,
/// Zip executable used for ZIP-inner bundle publication.
pub zip_command: PathBuf,
}
impl LocalizedPatchConfig {
/// Creates a localized patch configuration.
pub fn new(
official_release_root: impl Into<PathBuf>,
localized_output_root: impl Into<PathBuf>,
release_id: impl Into<String>,
patches: Vec<LocalizedTextAssetPatch>,
) -> Self {
Self {
official_release_root: official_release_root.into(),
localized_output_root: localized_output_root.into(),
release_id: release_id.into(),
localized_release_id: None,
force: false,
patches,
operations: Vec::new(),
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<String>) -> Self {
self.localized_release_id = Some(release_id.into());
self
}
/// Enables or disables forced publication.
pub fn with_force(mut self, force: bool) -> Self {
self.force = force;
self
}
/// Sets general localized patch operations.
pub fn with_operations(mut self, operations: Vec<LocalizedPatchInput>) -> Self {
self.operations = operations;
self
}
/// Sets 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<PathBuf>,
zip_command: impl Into<PathBuf>,
) -> Self {
self.unzip_command = unzip_command.into();
self.zip_command = zip_command.into();
self
}
fn published_release_id(&self) -> &str {
self.localized_release_id
.as_deref()
.unwrap_or(&self.release_id)
}
fn patch_operations(&self) -> Vec<LocalizedPatchInput> {
let mut operations = self
.patches
.iter()
.cloned()
.map(LocalizedPatchInput::TextAsset)
.collect::<Vec<_>>();
operations.extend(self.operations.iter().cloned());
operations
}
}
impl LocalizedPatchInput {
fn bundle_path(&self) -> &str {
match self {
Self::TextAsset(operation) => &operation.bundle_path,
Self::StringField(operation) => &operation.bundle_path,
Self::Field(operation) => &operation.bundle_path,
}
}
fn archive_entry(&self) -> Option<&str> {
match self {
Self::TextAsset(operation) => operation.archive_entry.as_deref(),
Self::StringField(operation) => operation.archive_entry.as_deref(),
Self::Field(operation) => operation.archive_entry.as_deref(),
}
}
fn apply(&self, input: &[u8]) -> anyhow::Result<Vec<u8>> {
match self {
Self::TextAsset(operation) => {
Ok(patch_unityfs_text_asset(input, &operation.text_asset)?)
}
Self::StringField(operation) => {
Ok(patch_unityfs_string_field(input, &operation.string_field)?)
}
Self::Field(operation) => Ok(patch_unityfs_field(input, &operation.field)?),
}
}
fn manifest_operation(&self) -> anyhow::Result<LocalizedPatchOperation> {
let mut operation = match self {
Self::TextAsset(operation) => Ok(LocalizedPatchOperation::from_text_asset_patch(
&operation.text_asset,
operation.metadata.as_ref(),
)),
Self::StringField(operation) => Ok(LocalizedPatchOperation::from_string_field_patch(
&operation.string_field,
operation.metadata.as_ref(),
)),
Self::Field(operation) => LocalizedPatchOperation::from_field_patch(
&operation.field,
operation.metadata.as_ref(),
),
}?;
operation.archive_entry = self.archive_entry().map(str::to_string);
Ok(operation)
}
}
/// Persisted localized release state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedVersionState {
/// State schema version.
#[serde(default = "default_localized_version_state_version")]
pub state_version: u32,
/// Official release ID used as the patch source.
pub official_release_id: String,
/// Published localized release ID.
pub current_release_id: Option<String>,
/// Stable status label.
pub status: String,
/// Review/proofreading workflow marker independent from publish status.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_workflow_status: Option<String>,
/// Last update time.
pub updated_unix_seconds: u64,
}
/// 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<String, usize>,
/// Actual metadata for every official manifest entry.
pub entries: Vec<LocalizedDistributionEntry>,
}
impl LocalizedVersionState {
/// Returns the stable translation workflow status, if set.
pub fn translation_workflow_status(&self) -> Option<&str> {
self.translation_workflow_status.as_deref()
}
/// Returns the user-facing translation workflow label, if set.
pub fn translation_workflow_label(&self) -> Option<&'static str> {
match self.translation_workflow_status() {
Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING) => {
Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL)
}
_ => None,
}
}
}
/// Report produced when changing localized translation workflow state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LocalizedTranslationWorkflowReport {
/// Stable command name.
pub command: &'static str,
/// Stable operation status.
pub status: &'static str,
/// Localized output root.
pub localized_output_root: PathBuf,
/// State file written under the localized output root.
pub state_path: PathBuf,
/// Official release associated with the workflow state.
pub official_release_id: String,
/// Current published localized release ID, when one exists.
pub current_release_id: Option<String>,
/// Stable localized publication label, such as `localized`.
pub localized_release_status: String,
/// Stable translation workflow marker.
pub translation_workflow_status: String,
/// Stable lifecycle status code for read-side callers.
pub translation_workflow_status_code: &'static str,
/// Human label for the workflow status.
pub translation_workflow_label: &'static str,
/// Whether an existing localized release remains publishable after the change.
pub publish_allowed: bool,
/// Last update time written to the state file.
pub updated_unix_seconds: u64,
}
/// One changed file in a localized patch manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchFile {
/// Relative bundle path.
pub path: String,
/// BLAKE3 before applying the patch.
pub original_blake3: String,
/// BLAKE3 after applying the patch.
pub localized_blake3: String,
/// Original file size in bytes.
#[serde(default)]
pub original_bytes: u64,
/// Localized file size in bytes.
#[serde(default)]
pub localized_bytes: u64,
/// Localized minus original byte size.
#[serde(default)]
pub byte_delta: i64,
/// TextAsset operations applied to this file.
#[serde(default)]
pub text_asset_operations: Vec<LocalizedPatchOperation>,
/// All operations applied to this file, including Binary/JSON/Text.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub operations: Vec<bat_patch::PatchManifestOperation>,
}
/// One TextAsset patch operation recorded in the localized patch manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchOperation {
/// Relative path of the UnityFS bundle inside an outer ZIP archive.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive_entry: Option<String>,
/// Patch operation kind, for example `unityfs_text_asset`.
#[serde(default = "default_patch_operation_kind")]
pub patch_kind: String,
/// BLAKE3 of the bytes immediately before this operation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_blake3: Option<String>,
/// Size of the bytes immediately before this operation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_bytes: Option<u64>,
/// UnityFS directory path of the serialized file.
pub serialized_file_path: String,
/// Unity object path ID.
pub path_id: i64,
/// TypeTree field path for field-level patches.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field_path: Option<String>,
/// Expected TextAsset name, when provided.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_name: Option<String>,
/// Replacement payload size.
pub replacement_bytes: u64,
/// BLAKE3 of the replacement payload.
pub replacement_blake3: String,
/// Replacement payload retained for generic manifest conversion.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replacement: Option<Vec<u8>>,
/// Expected source semantic/string value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_value: Option<UnitySerializedReplacementValue>,
/// Expected semantic value for a TypeTree field replacement.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replacement_value: Option<UnitySerializedReplacementValue>,
/// Stable TextUnit ID from the official TextUnit index.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_unit_id: Option<String>,
/// BLAKE3 of the source text validated before publication.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_text_blake3: Option<String>,
/// Translation provider that produced the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_provider: Option<String>,
/// Provider run ID that produced the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_run_id: Option<String>,
/// Source kind of the translation result.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_source_kind: Option<String>,
/// Trusted Translation Memory record used for the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_memory_record_id: Option<String>,
/// Review state used by the publication input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_status: Option<String>,
/// Glossary QA recomputed for this publication.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<bat_core::domain::GlossaryQaReport>,
/// Human confirmation bound to the published Glossary QA identity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<bat_core::domain::GlossaryOverride>,
}
/// Rollback information recorded for a localized publication.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchRollbackInfo {
/// Previous `current` symlink target before this publication.
pub previous_current_target: Option<PathBuf>,
/// Version directory that should be removed when rolling this publication back.
pub remove_version_path: PathBuf,
}
/// Integrity summary for a published localized release.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchIntegrity {
/// Number of changed files verified against the manifest.
pub verified_changed_file_count: usize,
/// Number of TextAsset operations recorded in the manifest.
pub verified_text_asset_operation_count: usize,
/// Whether `current` points at this localized release.
pub current_points_to_release: bool,
}
/// 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<String>,
/// All diagnostics from the read-only inspection.
pub diagnostics: Vec<String>,
}
/// Persisted manifest for one localized release.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchManifest {
/// Manifest schema version.
#[serde(default = "default_patch_manifest_version")]
pub manifest_version: u32,
/// Official release ID used as the patch source.
pub official_release_id: String,
/// Published localized release ID.
pub localized_release_id: String,
/// Manifest generation time as Unix seconds.
pub generated_unix_seconds: u64,
/// Changed file count.
pub file_count: usize,
/// TextAsset operation count.
pub text_asset_operation_count: usize,
/// Changed files and their before/after hashes.
pub files: Vec<LocalizedPatchFile>,
/// 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<bat_patch::PatchManifest>,
/// 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<bat_patch::PatchManifest> {
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::<anyhow::Result<Vec<_>>>()?
} 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::<Vec<_>>();
let patch_kind = operations
.iter()
.map(bat_patch::PatchManifestOperation::patch_kind)
.collect::<std::collections::BTreeSet<_>>();
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<LocalizedPatchFile>,
/// Persisted patch manifest.
pub manifest: LocalizedPatchManifest,
/// Integrity check performed after publication.
pub integrity: LocalizedPatchIntegrity,
}
/// Result of rolling back the current localized release.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LocalizedRollbackReport {
/// Stable command name.
pub command: &'static str,
/// Operation status.
pub status: &'static str,
/// Localized output root.
pub localized_output_root: PathBuf,
/// Atomic current pointer.
pub current_path: PathBuf,
/// Version state path.
pub state_path: PathBuf,
/// Localized release that was current before rollback.
pub rolled_back_release_id: String,
/// Removed current version directory.
pub removed_version_path: PathBuf,
/// Restored localized release ID, if a previous release existed.
#[serde(skip_serializing_if = "Option::is_none")]
pub restored_release_id: Option<String>,
/// Restored current symlink target, if a previous release existed.
#[serde(skip_serializing_if = "Option::is_none")]
pub restored_current_target: Option<PathBuf>,
/// New persisted localized version state.
pub state: LocalizedVersionState,
}
/// Applies supported UnityFS text patches and atomically publishes a localized release.
#[derive(Debug, Default, Clone, Copy)]
pub struct LocalizedPatchService;
impl LocalizedPatchService {
/// Creates the publisher.
pub fn new() -> Self {
Self
}
/// Copies the official release, applies patches in staging and publishes it.
pub fn publish(&self, config: &LocalizedPatchConfig) -> anyhow::Result<LocalizedPatchReport> {
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(&current_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,
&current_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<LocalizedRollbackReport> {
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(&current_release_id);
ensure_safe_directory_path(&version_path, "当前汉化 release")
.map_err(anyhow::Error::msg)?;
if !current_points_to_version(&current_path, &version_path)? {
return Err(anyhow::anyhow!(
"汉化 current 未指向当前状态 releasecurrent={} version={}",
current_path.display(),
version_path.display()
));
}
let manifest = read_localized_patch_manifest_at(&version_path)?.ok_or_else(|| {
anyhow::anyhow!(
"缺少当前汉化 release manifest{}",
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
)
})?;
verify_localized_release_files(&version_path, &manifest)?;
verify_localized_distribution_manifest_at(None, &version_path, &current_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(&current_release_id);
if lexical_absolute(&remove_version_path).map_err(anyhow::Error::msg)?
!= lexical_absolute(&expected_version_path).map_err(anyhow::Error::msg)?
{
return Err(anyhow::anyhow!(
"rollback manifest 删除路径必须指向当前 release{}",
current_release_id
));
}
ensure_path_within_root(localized_output_root, &remove_version_path)
.map_err(anyhow::Error::msg)?;
let restored_release_id =
match manifest.rollback.previous_current_target.as_deref() {
None => None,
Some(target) => Some(release_id_from_current_target(target).ok_or_else(|| {
anyhow::anyhow!("rollback manifest 上一 current 目标格式无效")
})?),
};
let mut restored_manifest = None;
if let Some(previous_target) = manifest.rollback.previous_current_target.as_deref() {
let previous_path = localized_output_root.join(previous_target);
ensure_path_within_root(localized_output_root, &previous_path)
.map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(&previous_path, "上一汉化 release")
.map_err(anyhow::Error::msg)?;
if !previous_path.is_dir() {
return Err(anyhow::anyhow!(
"rollback 记录的上一汉化 release 不存在:{}",
previous_path.display()
));
}
restored_manifest = Some(
read_localized_patch_manifest_at(&previous_path)?.ok_or_else(|| {
anyhow::anyhow!(
"rollback 记录的上一汉化 release 缺少 manifest{}",
previous_path.display()
)
})?,
);
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(&current_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,
&current_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(
&current_path,
&localized_output_root.join(previous_target),
)? {
return Err(anyhow::anyhow!("localized rollback current 最终校验失败"));
}
} else if fs::symlink_metadata(&current_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<PathBuf>,
) -> anyhow::Result<LocalizedPatchReport> {
validate_config(config).map_err(anyhow::Error::msg)?;
let staging = config
.localized_output_root
.join(LOCALIZED_STAGING_DIR)
.join(config.published_release_id());
let version_path = config
.localized_output_root
.join(LOCALIZED_VERSIONS_DIR)
.join(config.published_release_id());
let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
let state_path = config
.localized_output_root
.join(LOCALIZED_VERSION_STATE_FILE);
let versions_root = config.localized_output_root.join(LOCALIZED_VERSIONS_DIR);
let staging_root = config.localized_output_root.join(LOCALIZED_STAGING_DIR);
ensure_safe_directory_path(&versions_root, "汉化 versions 目录")
.map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(&staging_root, "汉化 staging 目录")
.map_err(anyhow::Error::msg)?;
let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE);
let previous_state = read_localized_version_state(&config.localized_output_root)?;
let translation_workflow_status = previous_state
.filter(|state| state.official_release_id == config.release_id)
.and_then(|state| state.translation_workflow_status);
match fs::symlink_metadata(&version_path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(anyhow::anyhow!(
"汉化 release 目标不能是 symlink{}",
version_path.display()
));
}
Ok(metadata) if !metadata.is_dir() => {
return Err(anyhow::anyhow!(
"汉化 release 目标已存在但不是目录:{}",
version_path.display()
));
}
Ok(_) => {
let message = if config.force {
"localized release target already exists; forced publication requires a distinct localized release id"
} else {
"localized release already exists"
};
return Err(anyhow::anyhow!("{message}: {}", version_path.display()));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
remove_owned_staging(&staging)?;
fs::create_dir_all(&staging)?;
copy_tree(&config.official_release_root, &staging)?;
let (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::<String, Vec<LocalizedPatchInput>>::new();
for operation in operations {
grouped
.entry(operation.bundle_path().to_string())
.or_default()
.push(operation);
}
let mut changed_files = Vec::with_capacity(grouped.len());
for (bundle_path, operations) in grouped {
let target = staging.join(Path::new(&bundle_path));
ensure_path_within_root(&staging, &target).map_err(anyhow::Error::msg)?;
ensure_safe_file_target(&staging, &target, "汉化 patch 输入")
.map_err(anyhow::Error::msg)?;
let original = fs::read(&target)?;
let (patched, manifest_operations) = if operations
.iter()
.any(|operation| operation.archive_entry().is_some())
{
if operations
.iter()
.any(|operation| operation.archive_entry().is_none())
{
return Err(anyhow::anyhow!(
"{bundle_path}: 不能混合直接 bundle patch 与 ZIP 内 bundle patch"
));
}
rewrite_zip_bundle(
&original,
&operations,
&config.unzip_command,
&config.zip_command,
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::<anyhow::Result<Vec<_>>>()?;
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,
&current_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,
&current_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<PathBuf>,
remove_target_path: PathBuf,
) -> anyhow::Result<bat_patch::PatchManifest> {
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::<std::collections::BTreeSet<_>>();
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::<anyhow::Result<Vec<_>>>()?;
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<Vec<LocalizedPatchFile>> {
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::<anyhow::Result<Vec<_>>>()?;
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::<anyhow::Result<Vec<_>>>()?;
(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::<anyhow::Result<Vec<_>>>()?;
(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<Vec<u8>> {
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<LocalizedPatchInput> {
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<Option<LocalizedPatchOperation>> {
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::<anyhow::Result<Vec<_>>>()?;
let operation_refs = operations.iter().collect::<Vec<_>>();
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<Option<LocalizedVersionState>> {
let path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE);
let Some(bytes) = read_file_no_symlink(&path, "汉化版本状态").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
let state: LocalizedVersionState = serde_json::from_slice(&bytes)?;
if state.state_version != LOCALIZED_VERSION_STATE_VERSION {
return Err(anyhow::anyhow!(
"不支持的汉化版本状态 schema{},当前版本=1",
state.state_version
));
}
Ok(Some(state))
}
/// Writes the localized version state atomically.
pub fn write_localized_version_state(
localized_output_root: &Path,
state: &LocalizedVersionState,
) -> anyhow::Result<PathBuf> {
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<PathBuf> {
ensure_safe_directory_path(localized_output_root, "汉化输出目录")
.map_err(anyhow::Error::msg)?;
let path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE);
write_file_atomic(
&path,
&serde_json::to_vec_pretty(state)?,
STATE_FILE_MODE,
"汉化版本状态",
)
.map_err(anyhow::Error::msg)?;
Ok(path)
}
/// Marks the current localized workflow as under manual proofreading without
/// changing the published localized release pointer.
pub fn mark_localized_manual_proofreading(
localized_output_root: &Path,
official_release_id: &str,
) -> anyhow::Result<LocalizedTranslationWorkflowReport> {
let _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<Option<LocalizedPatchManifest>> {
let path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE);
let Some(bytes) =
read_file_no_symlink(&path, "汉化 patch manifest").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
let manifest: LocalizedPatchManifest = serde_json::from_slice(&bytes)?;
if manifest.manifest_version != LOCALIZED_PATCH_MANIFEST_VERSION {
return Err(anyhow::anyhow!(
"不支持的汉化 patch manifest schema{},当前版本={}",
manifest.manifest_version,
LOCALIZED_PATCH_MANIFEST_VERSION
));
}
Ok(Some(manifest))
}
impl LocalizedPatchOperation {
fn from_text_asset_patch(
patch: &TextAssetPatch,
metadata: Option<&LocalizedPatchOperationMetadata>,
) -> Self {
Self::with_metadata(
Self {
archive_entry: None,
patch_kind: "unityfs_text_asset".to_string(),
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<Self> {
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<bat_patch::PatchManifestOperation> {
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<bat_patch::PatchManifestProvenance> {
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<LocalizedPatchIntegrity> {
let manifest = read_localized_patch_manifest_at(version_path)?.ok_or_else(|| {
anyhow::anyhow!(
"缺少汉化 patch manifest{}",
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
)
})?;
let mut integrity = verify_patch_manifest_files(
official_release_root,
version_path,
&manifest,
unzip_command,
)?;
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<Option<LocalizedDistributionManifest>> {
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::<BTreeMap<_, _>>();
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::<Vec<_>>();
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(&current_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, &current_path, Some(target))?;
} else if transaction.operation == "publish" {
restore_current_symlink(localized_output_root, &current_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<bool> {
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(&current_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 未指向 releasecurrent={} 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::<BTreeSet<_>>();
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<LocalizedPatchIntegrity> {
let mut operation_count = 0usize;
for file in &manifest.files {
let relative = Path::new(&file.path);
let official_path = official_release_root.join(relative);
let localized_path = localized_release_root.join(relative);
ensure_path_within_root(official_release_root, &official_path)
.map_err(anyhow::Error::msg)?;
ensure_path_within_root(localized_release_root, &localized_path)
.map_err(anyhow::Error::msg)?;
ensure_safe_file_target(official_release_root, &official_path, "官方 patch 原文件")
.map_err(anyhow::Error::msg)?;
ensure_safe_file_target(localized_release_root, &localized_path, "汉化 patch 产物")
.map_err(anyhow::Error::msg)?;
let original = fs::read(&official_path)?;
let localized = fs::read(&localized_path)?;
let original_hash = blake3::hash(&original).to_hex().to_string();
let localized_hash = blake3::hash(&localized).to_hex().to_string();
if original_hash != file.original_blake3 || original.len() as u64 != file.original_bytes {
return Err(anyhow::anyhow!(
"汉化 manifest 原文件校验失败 {}:期望 hash={} bytes={},实际 hash={} bytes={}",
file.path,
file.original_blake3,
file.original_bytes,
original_hash,
original.len()
));
}
if localized_hash != file.localized_blake3 || localized.len() as u64 != file.localized_bytes
{
return Err(anyhow::anyhow!(
"汉化 manifest 产物校验失败 {}:期望 hash={} bytes={},实际 hash={} bytes={}",
file.path,
file.localized_blake3,
file.localized_bytes,
localized_hash,
localized.len()
));
}
if localized_hash == original_hash {
return Err(anyhow::anyhow!(
"汉化 manifest 文件未发生变化:{}",
file.path
));
}
let archive_operations = file
.text_asset_operations
.iter()
.filter(|operation| operation.archive_entry.is_some())
.collect::<Vec<_>>();
if !archive_operations.is_empty() {
verify_zip_operations(unzip_command, &official_path, &archive_operations, false)?;
verify_zip_operations(unzip_command, &localized_path, &archive_operations, true)?;
}
operation_count += file.text_asset_operations.len();
}
if manifest.file_count != manifest.files.len() {
return Err(anyhow::anyhow!(
"汉化 manifest file_count 不一致:声明 {},实际 {}",
manifest.file_count,
manifest.files.len()
));
}
if manifest.text_asset_operation_count != operation_count {
return Err(anyhow::anyhow!(
"汉化 manifest operation_count 不一致:声明 {},实际 {}",
manifest.text_asset_operation_count,
operation_count
));
}
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<u8>, Vec<LocalizedPatchOperation>)> {
let archive_temp = tempfile::tempdir()?;
let archive_path = archive_temp.path().join("source.zip");
fs::write(&archive_path, original)?;
validate_zip_structure(&archive_path).map_err(anyhow::Error::msg)?;
let entries = list_archive_entries(unzip_command, &archive_path)?;
let extract_path = archive_temp.path().join("extract");
fs::create_dir(&extract_path)?;
let extract_output = Command::new(unzip_command)
.arg("-q")
.arg("-o")
.arg(&archive_path)
.arg("-d")
.arg(&extract_path)
.output()
.map_err(|error| anyhow::anyhow!("启动 unzip 解包 ZIP 失败:{error}"))?;
ensure_command_success(&extract_output, "解包 ZIP")?;
reject_extracted_symlinks(&extract_path)?;
let mut manifest_operations = Vec::with_capacity(operations.len());
let mut patched_entries = BTreeMap::<String, Vec<u8>>::new();
for (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(&current, generic_operation)?;
}
let patched = operation.apply(&current).map_err(|error| {
anyhow::anyhow!("{}!{}: {error}", operation.bundle_path(), archive_entry)
})?;
fs::write(&entry_path, &patched)?;
patched_entries.insert(archive_entry.to_string(), patched);
let mut manifest_operation = operation.manifest_operation()?;
manifest_operation.source_blake3 = Some(blake3::hash(&current).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<std::collections::BTreeSet<String>> {
let output = Command::new(unzip_command)
.arg("-Z1")
.arg(archive_path)
.output()
.map_err(|error| anyhow::anyhow!("启动 unzip 列出 ZIP 条目失败:{error}"))?;
ensure_command_success(&output, "列出 ZIP 条目")?;
let entries = std::str::from_utf8(&output.stdout)
.map_err(|_| anyhow::anyhow!("ZIP 条目列表不是 UTF-8{}", archive_path.display()))?
.lines()
.map(|line| line.trim_end_matches('\r').to_string())
.filter(|entry| !entry.is_empty() && !entry.ends_with('/'))
.collect::<std::collections::BTreeSet<_>>();
for entry in &entries {
validate_archive_member_name(entry)?;
}
Ok(entries)
}
fn verify_zip_operations(
unzip_command: &Path,
archive_path: &Path,
operations: &[&LocalizedPatchOperation],
verify_replacements: bool,
) -> anyhow::Result<()> {
validate_zip_structure(archive_path).map_err(anyhow::Error::msg)?;
let entries = list_archive_entries(unzip_command, archive_path)?;
for operation in operations {
let archive_entry = operation.archive_entry.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"manifest ZIP operation 缺少 archive_entry{}",
archive_path.display()
)
})?;
validate_archive_member_name(archive_entry)?;
if !entries.contains(archive_entry) {
return Err(anyhow::anyhow!(
"manifest ZIP operation 指向不存在的 archive entry{}!{}",
archive_path.display(),
archive_entry
));
}
let output = Command::new(unzip_command)
.arg("-p")
.arg(archive_path)
.arg(archive_entry)
.output()
.map_err(|error| anyhow::anyhow!("启动 unzip 读取 ZIP 条目失败:{error}"))?;
ensure_command_success(&output, "读取 ZIP 条目")?;
let parsed = bat_assetbundle::UnityFsParser::new()
.parse(&output.stdout)
.map_err(|error| {
anyhow::anyhow!(
"ZIP 内 UnityFS 重解析失败 {}!{}{error}",
archive_path.display(),
archive_entry
)
})?;
if verify_replacements {
verify_archive_operation_replacement(&parsed, operation, archive_entry)?;
}
}
Ok(())
}
fn verify_archive_operation_replacement(
parsed: &bat_assetbundle::ParsedAssetBundle,
operation: &LocalizedPatchOperation,
archive_entry: &str,
) -> anyhow::Result<()> {
let Some(serialized_file) = parsed
.serialized_files
.iter()
.find(|file| file.source_path.as_deref() == Some(operation.serialized_file_path.as_str()))
else {
return Err(anyhow::anyhow!(
"ZIP 内 UnityFS manifest serialized file 不存在:{}!{}:{}",
archive_entry,
operation.serialized_file_path,
operation.path_id
));
};
let bytes: Vec<u8> = match operation.patch_kind.as_str() {
"unityfs_text_asset" => parsed
.text_assets
.iter()
.find(|asset| {
asset.source_path.as_deref() == Some(operation.serialized_file_path.as_str())
&& asset.path_id == operation.path_id
})
.map(|asset| asset.bytes.clone())
.ok_or_else(|| {
anyhow::anyhow!(
"ZIP 内 UnityFS manifest TextAsset 不存在:{}!{}:{}",
archive_entry,
operation.serialized_file_path,
operation.path_id
)
})?,
"unityfs_string_field" => {
let field_path = operation.field_path.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"ZIP 内 UnityFS string operation 缺少 field_path{}!{}:{}",
archive_entry,
operation.serialized_file_path,
operation.path_id
)
})?;
let fields = serialized_file.fields_for_object(operation.path_id)?;
let value = find_archive_field_value(&fields, field_path).ok_or_else(|| {
anyhow::anyhow!(
"ZIP 内 UnityFS manifest 字段不存在:{}!{}:{}:{}",
archive_entry,
operation.serialized_file_path,
operation.path_id,
field_path
)
})?;
match value {
UnitySerializedValue::String(value) => value.as_bytes().to_vec(),
other => {
return Err(anyhow::anyhow!(
"ZIP 内 UnityFS manifest 字段不是 string{}!{}:{}:{} ({other:?})",
archive_entry,
operation.serialized_file_path,
operation.path_id,
field_path
));
}
}
}
"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<Option<PathBuf>> {
match fs::symlink_metadata(current_path) {
Ok(metadata) if metadata.file_type().is_symlink() => Ok(Some(fs::read_link(current_path)?)),
Ok(_) => Err(anyhow::anyhow!(
"汉化 current 已存在但不是 symlink{}",
current_path.display()
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
fn validate_previous_current_target(root: &Path, target: &Path) -> anyhow::Result<()> {
if release_id_from_current_target(target).is_none() {
return Err(anyhow::anyhow!(
"汉化 current 目标不是受支持的 versions/<release> 路径:{}",
target.display()
));
}
let target_path = root.join(target);
ensure_path_within_root(root, &target_path).map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(&target_path, "汉化 current 目标").map_err(anyhow::Error::msg)?;
if !target_path.is_dir() {
return Err(anyhow::anyhow!(
"汉化 current 目标 release 不存在:{}",
target_path.display()
));
}
Ok(())
}
fn current_points_to_version(current_path: &Path, version_path: &Path) -> anyhow::Result<bool> {
let metadata = fs::symlink_metadata(current_path)?;
if !metadata.file_type().is_symlink() {
return Ok(false);
}
Ok(fs::canonicalize(current_path)? == fs::canonicalize(version_path)?)
}
fn release_id_from_current_target(target: &Path) -> Option<String> {
let mut components = target.components();
match (components.next(), components.next(), components.next()) {
(
Some(std::path::Component::Normal(root)),
Some(std::path::Component::Normal(release_id)),
None,
) if root == LOCALIZED_VERSIONS_DIR => {
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<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64_be(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_le_bytes());
}
fn push_i32_le(data: &mut Vec<u8>, 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<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}
fn align_vec(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn push_c_string(data: &mut Vec<u8>, value: &str) {
data.extend_from_slice(value.as_bytes());
data.push(0);
}
fn synthetic_serialized_text_asset(bytes: &[u8]) -> Vec<u8> {
let mut object_data = Vec::new();
push_u32_le(&mut object_data, 8);
object_data.extend_from_slice(b"Scenario");
align_vec(&mut object_data, 4);
push_u32_le(&mut object_data, bytes.len() as u32);
object_data.extend_from_slice(bytes);
let mut metadata = Vec::new();
metadata.extend_from_slice(b"2021.3.56f2\0");
metadata.extend_from_slice(&19i32.to_le_bytes());
metadata.push(0);
metadata.extend_from_slice(&1i32.to_le_bytes());
metadata.extend_from_slice(&49i32.to_le_bytes());
metadata.push(0);
push_i16_le(&mut metadata, 0);
metadata.extend_from_slice(&[0; 16]);
metadata.extend_from_slice(&1i32.to_le_bytes());
align_vec(&mut metadata, 4);
push_u64_le(&mut metadata, 1);
push_u64_le(&mut metadata, 0);
push_u32_le(&mut metadata, object_data.len() as u32);
push_u32_le(&mut metadata, 0);
let header_len = 48usize;
let data_offset = header_len + metadata.len();
let file_size = data_offset + object_data.len();
let mut file = Vec::new();
push_u32_be(&mut file, metadata.len() as u32);
push_u32_be(&mut file, file_size as u32);
push_u32_be(&mut file, 22);
push_u32_be(&mut file, 0);
file.extend_from_slice(&[0, 0, 0, 0]);
push_u32_be(&mut file, metadata.len() as u32);
push_u64_be(&mut file, file_size as u64);
push_u64_be(&mut file, data_offset as u64);
push_u64_be(&mut file, 0);
file.extend_from_slice(&metadata);
file.extend_from_slice(&object_data);
file
}
fn synthetic_serialized_monobehaviour() -> Vec<u8> {
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<u8> {
let mut blocks_info = vec![0; 16];
blocks_info.extend_from_slice(&1i32.to_be_bytes());
push_u32_be(&mut blocks_info, payload.len() as u32);
push_u32_be(&mut blocks_info, payload.len() as u32);
blocks_info.extend_from_slice(&0u16.to_be_bytes());
blocks_info.extend_from_slice(&1i32.to_be_bytes());
push_u64_be(&mut blocks_info, 0);
push_u64_be(&mut blocks_info, payload.len() as u64);
push_u32_be(&mut blocks_info, 0);
push_c_string(&mut blocks_info, "CAB-asset");
let mut file = Vec::new();
push_c_string(&mut file, "UnityFS");
push_u32_be(&mut file, 8);
push_c_string(&mut file, "5.x.x");
push_c_string(&mut file, "2021.3.56f2");
let total_size_offset = file.len();
push_u64_be(&mut file, 0);
push_u32_be(&mut file, blocks_info.len() as u32);
push_u32_be(&mut file, blocks_info.len() as u32);
push_u32_be(&mut file, 0);
align_vec(&mut file, 16);
file.extend_from_slice(&blocks_info);
file.extend_from_slice(payload);
let total_size = file.len() as u64;
file[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
file
}
#[test]
fn localized_manifest_converts_to_generic_patch_manifest() {
let source = b"source";
let target = b"target";
let manifest = LocalizedPatchManifest {
manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION,
official_release_id: "official-v1".to_string(),
localized_release_id: "localized-v1".to_string(),
generated_unix_seconds: 123,
file_count: 1,
text_asset_operation_count: 1,
files: vec![LocalizedPatchFile {
path: "Bundles/file.bundle".to_string(),
original_blake3: blake3::hash(source).to_hex().to_string(),
localized_blake3: blake3::hash(target).to_hex().to_string(),
original_bytes: source.len() as u64,
localized_bytes: target.len() as u64,
byte_delta: target.len() as i64 - source.len() as i64,
text_asset_operations: vec![LocalizedPatchOperation {
archive_entry: None,
patch_kind: "unityfs_text_asset".to_string(),
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<_>>(),
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"),
&current,
)
.unwrap();
rollback_failed_publish(
&localized,
&staging,
&failed_version,
true,
&current,
Some(&PathBuf::from("versions/release-old")),
(&state_path, Some(previous_state)),
)
.unwrap();
assert!(!failed_version.exists());
assert!(!staging.exists());
assert_eq!(
fs::read_link(&current).unwrap(),
PathBuf::from("versions/release-old")
);
assert_eq!(fs::read(&state_path).unwrap(), previous_state);
}
}