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