mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-19 10:26:39 +08:00
4823 lines
190 KiB
Rust
4823 lines
190 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, 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(¤t_path)?;
|
||
if let Some(target) = previous_current_target.as_deref() {
|
||
validate_previous_current_target(&config.localized_output_root, target)?;
|
||
}
|
||
let transaction = LocalizedReleaseTransaction::publish(
|
||
&published_release_id,
|
||
version_path.clone(),
|
||
staging.clone(),
|
||
previous_current_target.clone(),
|
||
previous_state_bytes.clone(),
|
||
);
|
||
write_localized_transaction(&config.localized_output_root, &transaction)?;
|
||
let version_existed_before = version_path.exists();
|
||
match self.publish_inner(config, previous_current_target.clone()) {
|
||
Ok(report) => {
|
||
remove_localized_transaction(&config.localized_output_root)?;
|
||
Ok(report)
|
||
}
|
||
Err(error) => {
|
||
if let Err(rollback_error) = rollback_failed_publish(
|
||
&config.localized_output_root,
|
||
&staging,
|
||
&version_path,
|
||
!version_existed_before,
|
||
¤t_path,
|
||
previous_current_target.as_ref(),
|
||
(&state_path, previous_state_bytes.as_deref()),
|
||
) {
|
||
return Err(anyhow::anyhow!(
|
||
"{error}; rollback failed: {rollback_error}"
|
||
));
|
||
}
|
||
remove_localized_transaction(&config.localized_output_root)?;
|
||
Err(error)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Rolls back the current localized release to the manifest-recorded
|
||
/// previous current target.
|
||
pub fn rollback(
|
||
&self,
|
||
localized_output_root: &Path,
|
||
expected_release_id: Option<&str>,
|
||
) -> anyhow::Result<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(¤t_release_id);
|
||
ensure_safe_directory_path(&version_path, "当前汉化 release")
|
||
.map_err(anyhow::Error::msg)?;
|
||
if !current_points_to_version(¤t_path, &version_path)? {
|
||
return Err(anyhow::anyhow!(
|
||
"汉化 current 未指向当前状态 release:current={} version={}",
|
||
current_path.display(),
|
||
version_path.display()
|
||
));
|
||
}
|
||
let manifest = read_localized_patch_manifest_at(&version_path)?.ok_or_else(|| {
|
||
anyhow::anyhow!(
|
||
"缺少当前汉化 release manifest:{}",
|
||
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
|
||
)
|
||
})?;
|
||
verify_localized_release_files(&version_path, &manifest)?;
|
||
verify_localized_distribution_manifest_at(None, &version_path, ¤t_release_id)?;
|
||
if manifest.localized_release_id != current_release_id {
|
||
return Err(anyhow::anyhow!(
|
||
"manifest release={} 与当前状态 release={} 不一致",
|
||
manifest.localized_release_id,
|
||
current_release_id
|
||
));
|
||
}
|
||
if manifest.official_release_id != state.official_release_id {
|
||
return Err(anyhow::anyhow!(
|
||
"manifest 官方 release={} 与状态 release={} 不一致",
|
||
manifest.official_release_id,
|
||
state.official_release_id
|
||
));
|
||
}
|
||
let remove_version_path = manifest.rollback.remove_version_path.clone();
|
||
let expected_version_path = localized_output_root
|
||
.join(LOCALIZED_VERSIONS_DIR)
|
||
.join(¤t_release_id);
|
||
if lexical_absolute(&remove_version_path).map_err(anyhow::Error::msg)?
|
||
!= lexical_absolute(&expected_version_path).map_err(anyhow::Error::msg)?
|
||
{
|
||
return Err(anyhow::anyhow!(
|
||
"rollback manifest 删除路径必须指向当前 release:{}",
|
||
current_release_id
|
||
));
|
||
}
|
||
ensure_path_within_root(localized_output_root, &remove_version_path)
|
||
.map_err(anyhow::Error::msg)?;
|
||
let restored_release_id =
|
||
match manifest.rollback.previous_current_target.as_deref() {
|
||
None => None,
|
||
Some(target) => Some(release_id_from_current_target(target).ok_or_else(|| {
|
||
anyhow::anyhow!("rollback manifest 上一 current 目标格式无效")
|
||
})?),
|
||
};
|
||
let mut restored_manifest = None;
|
||
if let Some(previous_target) = manifest.rollback.previous_current_target.as_deref() {
|
||
let previous_path = localized_output_root.join(previous_target);
|
||
ensure_path_within_root(localized_output_root, &previous_path)
|
||
.map_err(anyhow::Error::msg)?;
|
||
ensure_safe_directory_path(&previous_path, "上一汉化 release")
|
||
.map_err(anyhow::Error::msg)?;
|
||
if !previous_path.is_dir() {
|
||
return Err(anyhow::anyhow!(
|
||
"rollback 记录的上一汉化 release 不存在:{}",
|
||
previous_path.display()
|
||
));
|
||
}
|
||
restored_manifest = Some(
|
||
read_localized_patch_manifest_at(&previous_path)?.ok_or_else(|| {
|
||
anyhow::anyhow!(
|
||
"rollback 记录的上一汉化 release 缺少 manifest:{}",
|
||
previous_path.display()
|
||
)
|
||
})?,
|
||
);
|
||
verify_localized_release_files(&previous_path, restored_manifest.as_ref().unwrap())?;
|
||
verify_localized_distribution_manifest_at(
|
||
None,
|
||
&previous_path,
|
||
restored_release_id.as_deref().unwrap_or_default(),
|
||
)?;
|
||
}
|
||
|
||
let previous_state_bytes = read_file_no_symlink(&state_path, "汉化版本状态")
|
||
.map_err(anyhow::Error::msg)?
|
||
.ok_or_else(|| anyhow::anyhow!("缺少汉化版本状态"))?;
|
||
let restored_official_release_id = restored_manifest
|
||
.as_ref()
|
||
.map(|manifest| manifest.official_release_id.clone())
|
||
.unwrap_or_else(|| state.official_release_id.clone());
|
||
let previous_workflow_status = state.translation_workflow_status.clone();
|
||
let translation_workflow_status =
|
||
if restored_official_release_id == state.official_release_id {
|
||
previous_workflow_status
|
||
} else {
|
||
None
|
||
};
|
||
let new_state = LocalizedVersionState {
|
||
state_version: LOCALIZED_VERSION_STATE_VERSION,
|
||
official_release_id: restored_official_release_id,
|
||
current_release_id: restored_release_id.clone(),
|
||
status: if restored_release_id.is_some() {
|
||
"localized".to_string()
|
||
} else {
|
||
"not_localized".to_string()
|
||
},
|
||
translation_workflow_status,
|
||
updated_unix_seconds: unix_seconds_now(),
|
||
};
|
||
let transaction = LocalizedReleaseTransaction {
|
||
version: 1,
|
||
operation: "rollback".to_string(),
|
||
phase: "prepared".to_string(),
|
||
release_id: current_release_id.clone(),
|
||
version_path: remove_version_path.clone(),
|
||
staging_path: None,
|
||
previous_current_target: Some(
|
||
Path::new(LOCALIZED_VERSIONS_DIR).join(¤t_release_id),
|
||
),
|
||
current_target: manifest.rollback.previous_current_target.clone(),
|
||
previous_state_bytes: Some(previous_state_bytes),
|
||
new_state: Some(new_state.clone()),
|
||
rollback_backup_path: Some(localized_output_root.join(".rollback").join(format!(
|
||
"{}.{}",
|
||
current_release_id,
|
||
std::process::id()
|
||
))),
|
||
};
|
||
write_localized_transaction(localized_output_root, &transaction)?;
|
||
let mutation_result = (|| -> anyhow::Result<()> {
|
||
let backup_path = transaction
|
||
.rollback_backup_path
|
||
.as_deref()
|
||
.ok_or_else(|| anyhow::anyhow!("localized rollback 缺少备份路径"))?;
|
||
ensure_path_within_root(localized_output_root, backup_path)
|
||
.map_err(anyhow::Error::msg)?;
|
||
ensure_safe_directory_path(
|
||
backup_path.parent().unwrap_or(localized_output_root),
|
||
"localized rollback 备份目录",
|
||
)
|
||
.map_err(anyhow::Error::msg)?;
|
||
fs::create_dir_all(backup_path.parent().unwrap_or(localized_output_root))?;
|
||
ensure_safe_directory_path(
|
||
backup_path.parent().unwrap_or(localized_output_root),
|
||
"localized rollback 备份目录",
|
||
)
|
||
.map_err(anyhow::Error::msg)?;
|
||
fs::rename(&remove_version_path, backup_path)?;
|
||
update_localized_transaction_phase(localized_output_root, "version_staged")?;
|
||
restore_current_symlink(
|
||
localized_output_root,
|
||
¤t_path,
|
||
manifest.rollback.previous_current_target.as_deref(),
|
||
)?;
|
||
update_localized_transaction_phase(localized_output_root, "current_switched")?;
|
||
write_localized_version_state_unlocked(localized_output_root, &new_state)?;
|
||
update_localized_transaction_phase(localized_output_root, "state_written")?;
|
||
if let Some(previous_target) = manifest.rollback.previous_current_target.as_deref() {
|
||
if !current_points_to_version(
|
||
¤t_path,
|
||
&localized_output_root.join(previous_target),
|
||
)? {
|
||
return Err(anyhow::anyhow!("localized rollback current 最终校验失败"));
|
||
}
|
||
} else if fs::symlink_metadata(¤t_path).is_ok() {
|
||
return Err(anyhow::anyhow!("localized rollback 应移除 current 指针"));
|
||
}
|
||
update_localized_transaction_phase(localized_output_root, "version_removed")?;
|
||
remove_owned_path(backup_path)?;
|
||
Ok(())
|
||
})();
|
||
if let Err(error) = mutation_result {
|
||
let recovery = recover_localized_transaction(localized_output_root);
|
||
return match recovery {
|
||
Ok(()) => Err(error),
|
||
Err(recovery_error) => Err(anyhow::anyhow!(
|
||
"{error}; localized rollback recovery failed: {recovery_error}"
|
||
)),
|
||
};
|
||
}
|
||
remove_localized_transaction(localized_output_root)?;
|
||
|
||
Ok(LocalizedRollbackReport {
|
||
command: "localized.rollback",
|
||
status: "rolled_back",
|
||
localized_output_root: localized_output_root.to_path_buf(),
|
||
current_path,
|
||
state_path,
|
||
rolled_back_release_id: current_release_id,
|
||
removed_version_path: remove_version_path,
|
||
restored_release_id,
|
||
restored_current_target: manifest.rollback.previous_current_target,
|
||
state: new_state,
|
||
})
|
||
}
|
||
|
||
fn publish_inner(
|
||
&self,
|
||
config: &LocalizedPatchConfig,
|
||
previous_current_target: Option<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,
|
||
¤t_path,
|
||
config.published_release_id(),
|
||
)?;
|
||
update_localized_transaction_phase(&config.localized_output_root, "current_switched")?;
|
||
|
||
let state = LocalizedVersionState {
|
||
state_version: LOCALIZED_VERSION_STATE_VERSION,
|
||
official_release_id: config.release_id.clone(),
|
||
current_release_id: Some(config.published_release_id().to_string()),
|
||
status: "localized".to_string(),
|
||
translation_workflow_status,
|
||
updated_unix_seconds: unix_seconds_now(),
|
||
};
|
||
update_localized_transaction_state(&config.localized_output_root, &state)?;
|
||
write_file_atomic(
|
||
&state_path,
|
||
&serde_json::to_vec_pretty(&state)?,
|
||
STATE_FILE_MODE,
|
||
"汉化版本状态",
|
||
)
|
||
.map_err(anyhow::Error::msg)?;
|
||
update_localized_transaction_phase(&config.localized_output_root, "state_written")?;
|
||
let integrity = verify_published_localized_release(
|
||
&config.official_release_root,
|
||
&version_path,
|
||
¤t_path,
|
||
&config.unzip_command,
|
||
)?;
|
||
update_localized_transaction_phase(&config.localized_output_root, "verified")?;
|
||
|
||
Ok(LocalizedPatchReport {
|
||
version_path,
|
||
current_path,
|
||
state_path,
|
||
patch_manifest_path,
|
||
files: changed_files,
|
||
manifest,
|
||
integrity,
|
||
})
|
||
}
|
||
}
|
||
|
||
fn generic_manifest_from_localized_files(
|
||
source_version: &str,
|
||
target_version: &str,
|
||
files: &[LocalizedPatchFile],
|
||
previous_current_target: Option<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(¤t_path),
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound
|
||
),
|
||
};
|
||
let state_matches = transaction.new_state.as_ref().is_some_and(|expected| {
|
||
read_localized_version_state(localized_output_root)
|
||
.ok()
|
||
.flatten()
|
||
.as_ref()
|
||
== Some(expected)
|
||
});
|
||
let publish_committed = transaction.operation == "publish"
|
||
&& transaction.phase == "verified"
|
||
&& transaction.version_path.is_dir()
|
||
&& current_matches
|
||
&& state_matches;
|
||
let rollback_committed = transaction.operation == "rollback"
|
||
&& transaction.phase == "version_removed"
|
||
&& current_matches
|
||
&& state_matches;
|
||
|
||
if publish_committed {
|
||
if let Some(staging) = transaction.staging_path.as_deref() {
|
||
remove_owned_path(staging)?;
|
||
}
|
||
} else if rollback_committed {
|
||
remove_owned_path(&transaction.version_path)?;
|
||
if let Some(backup) = transaction.rollback_backup_path.as_deref() {
|
||
remove_owned_path(backup)?;
|
||
}
|
||
} else {
|
||
if let Some(target) = transaction.previous_current_target.as_deref() {
|
||
let target_path = localized_output_root.join(target);
|
||
ensure_path_within_root(localized_output_root, &target_path)
|
||
.map_err(anyhow::Error::msg)?;
|
||
restore_current_symlink(localized_output_root, ¤t_path, Some(target))?;
|
||
} else if transaction.operation == "publish" {
|
||
restore_current_symlink(localized_output_root, ¤t_path, None)?;
|
||
}
|
||
if transaction.operation == "publish" {
|
||
if let Some(staging) = transaction.staging_path.as_deref() {
|
||
remove_owned_path(staging)?;
|
||
}
|
||
remove_owned_path(&transaction.version_path)?;
|
||
}
|
||
if transaction.operation == "rollback" {
|
||
if let Some(backup) = transaction.rollback_backup_path.as_deref() {
|
||
if fs::symlink_metadata(backup).is_ok() {
|
||
remove_owned_path(&transaction.version_path)?;
|
||
fs::rename(backup, &transaction.version_path)?;
|
||
}
|
||
}
|
||
}
|
||
if let Some(previous_state) = transaction.previous_state_bytes.as_deref() {
|
||
write_file_atomic(
|
||
&localized_output_root.join(LOCALIZED_VERSION_STATE_FILE),
|
||
previous_state,
|
||
STATE_FILE_MODE,
|
||
"恢复 localized version state",
|
||
)
|
||
.map_err(anyhow::Error::msg)?;
|
||
} else {
|
||
remove_owned_path(&localized_output_root.join(LOCALIZED_VERSION_STATE_FILE))?;
|
||
}
|
||
}
|
||
remove_localized_transaction(localized_output_root)
|
||
}
|
||
|
||
pub(crate) fn recover_localized_output_transaction(root: &Path) -> anyhow::Result<()> {
|
||
recover_localized_transaction(root)
|
||
}
|
||
|
||
pub(crate) fn localized_output_transaction_pending(root: &Path) -> anyhow::Result<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(¤t_path, &version_path).unwrap_or(false);
|
||
let candidate_exists = fs::symlink_metadata(&version_path)
|
||
.map(|metadata| metadata.is_dir())
|
||
.unwrap_or(false);
|
||
if !candidate_exists {
|
||
diagnostics.push(format!(
|
||
"localized release 目录不存在:{}",
|
||
version_path.display()
|
||
));
|
||
return LocalizedArtifactIntegrityReport {
|
||
manifest_contract_status: "missing".to_string(),
|
||
artifact_integrity_status: "unavailable".to_string(),
|
||
verified: false,
|
||
current_points_to_release,
|
||
manifest_available: false,
|
||
manifest_matches_release: false,
|
||
error: diagnostics.first().cloned(),
|
||
diagnostics,
|
||
};
|
||
}
|
||
if require_current_pointer && !current_points_to_release {
|
||
diagnostics.push(format!(
|
||
"localized current 未指向 release:current={} version={}",
|
||
current_path.display(),
|
||
version_path.display()
|
||
));
|
||
}
|
||
if let Err(error) = ensure_safe_directory_path(&version_path, "汉化 release") {
|
||
diagnostics.push(error.to_string());
|
||
}
|
||
|
||
let manifest_available = fs::symlink_metadata(version_path.join(LOCALIZED_PATCH_MANIFEST_FILE))
|
||
.map(|metadata| !metadata.file_type().is_symlink())
|
||
.unwrap_or(false);
|
||
let manifest = match read_localized_patch_manifest_at(&version_path) {
|
||
Ok(Some(manifest)) => manifest,
|
||
Ok(None) => {
|
||
diagnostics.push(format!(
|
||
"缺少汉化 patch manifest:{}",
|
||
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
|
||
));
|
||
return LocalizedArtifactIntegrityReport {
|
||
manifest_contract_status: "missing".to_string(),
|
||
artifact_integrity_status: "invalid".to_string(),
|
||
verified: false,
|
||
current_points_to_release,
|
||
manifest_available,
|
||
manifest_matches_release: false,
|
||
error: diagnostics.first().cloned(),
|
||
diagnostics,
|
||
};
|
||
}
|
||
Err(error) => {
|
||
diagnostics.push(error.to_string());
|
||
return LocalizedArtifactIntegrityReport {
|
||
manifest_contract_status: "invalid".to_string(),
|
||
artifact_integrity_status: "invalid".to_string(),
|
||
verified: false,
|
||
current_points_to_release,
|
||
manifest_available,
|
||
manifest_matches_release: false,
|
||
error: diagnostics.first().cloned(),
|
||
diagnostics,
|
||
};
|
||
}
|
||
};
|
||
let wrapper_matches_release = manifest.official_release_id == expected_official_release_id
|
||
&& manifest.localized_release_id == localized_release_id;
|
||
if !wrapper_matches_release {
|
||
diagnostics.push(format!(
|
||
"localized manifest identity 不匹配:official={} localized={}",
|
||
manifest.official_release_id, manifest.localized_release_id
|
||
));
|
||
}
|
||
let generic_matches_wrapper = manifest.patch_manifest.as_ref().is_none_or(|generic| {
|
||
generic.source_version == manifest.official_release_id
|
||
&& generic.target_version == manifest.localized_release_id
|
||
});
|
||
if !generic_matches_wrapper {
|
||
diagnostics.push(
|
||
"generic manifest source_version/target_version 与 localized wrapper 不一致"
|
||
.to_string(),
|
||
);
|
||
}
|
||
let manifest_matches_release = wrapper_matches_release && generic_matches_wrapper;
|
||
let manifest_contract_status = if let Some(generic) = manifest.patch_manifest.as_ref() {
|
||
match bat_patch::validate_patch_manifest(generic) {
|
||
Ok(()) if manifest_matches_release => "valid",
|
||
Ok(()) => "invalid",
|
||
Err(error) => {
|
||
diagnostics.push(format!("generic manifest schema 无效:{error}"));
|
||
"invalid"
|
||
}
|
||
}
|
||
} else {
|
||
"legacy"
|
||
};
|
||
if manifest_contract_status == "invalid" {
|
||
return LocalizedArtifactIntegrityReport {
|
||
manifest_contract_status: manifest_contract_status.to_string(),
|
||
artifact_integrity_status: "invalid".to_string(),
|
||
verified: false,
|
||
current_points_to_release,
|
||
manifest_available,
|
||
manifest_matches_release,
|
||
error: diagnostics.first().cloned(),
|
||
diagnostics,
|
||
};
|
||
}
|
||
if let Err(error) = verify_patch_manifest_files(
|
||
official_release_root,
|
||
&version_path,
|
||
&manifest,
|
||
unzip_command,
|
||
) {
|
||
diagnostics.push(error.to_string());
|
||
}
|
||
if manifest.patch_manifest.is_some() {
|
||
match crate::official_download::read_download_manifest_at(official_release_root) {
|
||
Ok(Some(download_manifest)) => {
|
||
if let Err(error) = verify_localized_resource_root(
|
||
official_release_root,
|
||
&version_path,
|
||
&manifest,
|
||
&download_manifest,
|
||
) {
|
||
diagnostics.push(error.to_string());
|
||
}
|
||
}
|
||
Ok(None) => diagnostics.push(format!(
|
||
"缺少官方 download manifest,无法完成 generic release 全量完整性检查:{}",
|
||
official_release_root.display()
|
||
)),
|
||
Err(error) => diagnostics.push(error),
|
||
}
|
||
}
|
||
let verified = diagnostics.is_empty()
|
||
&& (!require_current_pointer || current_points_to_release)
|
||
&& manifest_matches_release;
|
||
LocalizedArtifactIntegrityReport {
|
||
manifest_contract_status: manifest_contract_status.to_string(),
|
||
artifact_integrity_status: if verified {
|
||
"valid".to_string()
|
||
} else {
|
||
"invalid".to_string()
|
||
},
|
||
verified,
|
||
current_points_to_release,
|
||
manifest_available,
|
||
manifest_matches_release,
|
||
error: diagnostics.first().cloned(),
|
||
diagnostics,
|
||
}
|
||
}
|
||
|
||
fn verify_localized_resource_root(
|
||
official_release_root: &Path,
|
||
localized_release_root: &Path,
|
||
localized_manifest: &LocalizedPatchManifest,
|
||
download_manifest: &crate::official_download::OfficialDownloadManifest,
|
||
) -> anyhow::Result<()> {
|
||
let changed_paths = localized_manifest
|
||
.files
|
||
.iter()
|
||
.map(|file| file.path.as_str())
|
||
.collect::<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(¤t, generic_operation)?;
|
||
}
|
||
let patched = operation.apply(¤t).map_err(|error| {
|
||
anyhow::anyhow!("{}!{}: {error}", operation.bundle_path(), archive_entry)
|
||
})?;
|
||
fs::write(&entry_path, &patched)?;
|
||
patched_entries.insert(archive_entry.to_string(), patched);
|
||
let mut manifest_operation = operation.manifest_operation()?;
|
||
manifest_operation.source_blake3 = Some(blake3::hash(¤t).to_hex().to_string());
|
||
manifest_operation.source_bytes = Some(current.len() as u64);
|
||
manifest_operations.push(manifest_operation);
|
||
}
|
||
|
||
let output_path = archive_temp.path().join("localized.zip");
|
||
let zip_output = Command::new(zip_command)
|
||
.current_dir(&extract_path)
|
||
.arg("-q")
|
||
.arg("-r")
|
||
.arg(&output_path)
|
||
.arg(".")
|
||
.output()
|
||
.map_err(|error| anyhow::anyhow!("启动 zip 重打包 ZIP 失败:{error}"))?;
|
||
ensure_command_success(&zip_output, "重打包 ZIP")?;
|
||
validate_zip_structure(&output_path).map_err(anyhow::Error::msg)?;
|
||
let patched = fs::read(output_path)?;
|
||
Ok((patched, manifest_operations))
|
||
}
|
||
|
||
fn list_archive_entries(
|
||
unzip_command: &Path,
|
||
archive_path: &Path,
|
||
) -> anyhow::Result<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"),
|
||
¤t,
|
||
)
|
||
.unwrap();
|
||
|
||
rollback_failed_publish(
|
||
&localized,
|
||
&staging,
|
||
&failed_version,
|
||
true,
|
||
¤t,
|
||
Some(&PathBuf::from("versions/release-old")),
|
||
(&state_path, Some(previous_state)),
|
||
)
|
||
.unwrap();
|
||
|
||
assert!(!failed_version.exists());
|
||
assert!(!staging.exists());
|
||
assert_eq!(
|
||
fs::read_link(¤t).unwrap(),
|
||
PathBuf::from("versions/release-old")
|
||
);
|
||
assert_eq!(fs::read(&state_path).unwrap(), previous_state);
|
||
}
|
||
}
|