mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 10:00:40 +08:00
feat(sync): 接入解析缓存与汉化发布前置
补齐官方 release 解析缓存、TextUnit 明细索引、资源变更集、Crowdin handoff 预留、ResourceRepository 导入元数据和 localized release patch 前置链路。 同时开放文件级 patch.apply 与 UnityFS TextAsset/string/semantic field patch CLI/RPC 入口,并保留官方原版资源与汉化产物双目录发布状态。 验证:cargo test -p bat-assetbundle --locked;cargo clippy -p bat-assetbundle --all-targets --locked -- -D warnings;cargo test -p bat-infrastructure --locked。
This commit is contained in:
@@ -0,0 +1,857 @@
|
||||
//! Localized release publishing for verified TextAsset patches.
|
||||
|
||||
use bat_assetbundle::{patch_unityfs_text_asset, TextAssetPatch};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::path_security::{
|
||||
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
|
||||
read_file_no_symlink, write_file_atomic, STATE_FILE_MODE,
|
||||
};
|
||||
|
||||
/// Atomic current pointer under the localized output root.
|
||||
pub const LOCALIZED_CURRENT_LINK: &str = "current";
|
||||
/// Staging directory under the localized output root.
|
||||
pub const LOCALIZED_STAGING_DIR: &str = ".staging";
|
||||
/// Version directory under the localized output root.
|
||||
pub const LOCALIZED_VERSIONS_DIR: &str = "versions";
|
||||
/// Persisted localized release state file name.
|
||||
pub const LOCALIZED_VERSION_STATE_FILE: &str = "localized-version-state.json";
|
||||
/// Per-release patch manifest file name.
|
||||
pub const LOCALIZED_PATCH_MANIFEST_FILE: &str = "localized-patch-manifest.json";
|
||||
/// Current localized patch manifest schema version.
|
||||
pub const LOCALIZED_PATCH_MANIFEST_VERSION: u32 = 1;
|
||||
|
||||
/// One patch operation against a bundle in an official release.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LocalizedTextAssetPatch {
|
||||
/// Relative path of the UnityFS bundle under the official release.
|
||||
pub bundle_path: String,
|
||||
/// TextAsset replacement inside the bundle.
|
||||
pub text_asset: TextAssetPatch,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Patch operations to apply.
|
||||
pub patches: Vec<LocalizedTextAssetPatch>,
|
||||
}
|
||||
|
||||
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(),
|
||||
patches,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted localized release state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalizedVersionState {
|
||||
/// State schema 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,
|
||||
/// Last update time.
|
||||
pub updated_unix_seconds: u64,
|
||||
}
|
||||
|
||||
/// One changed file in a localized patch manifest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalizedPatchFile {
|
||||
/// Relative bundle path.
|
||||
pub path: String,
|
||||
/// BLAKE3 before applying the patch.
|
||||
pub original_blake3: String,
|
||||
/// BLAKE3 after applying the patch.
|
||||
pub localized_blake3: String,
|
||||
/// Original file size in bytes.
|
||||
#[serde(default)]
|
||||
pub original_bytes: u64,
|
||||
/// Localized file size in bytes.
|
||||
#[serde(default)]
|
||||
pub localized_bytes: u64,
|
||||
/// Localized minus original byte size.
|
||||
#[serde(default)]
|
||||
pub byte_delta: i64,
|
||||
/// TextAsset operations applied to this file.
|
||||
#[serde(default)]
|
||||
pub text_asset_operations: Vec<LocalizedPatchOperation>,
|
||||
}
|
||||
|
||||
/// One TextAsset patch operation recorded in the localized patch manifest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalizedPatchOperation {
|
||||
/// UnityFS directory path of the serialized file.
|
||||
pub serialized_file_path: String,
|
||||
/// Unity object path ID.
|
||||
pub path_id: i64,
|
||||
/// Expected TextAsset name, when provided.
|
||||
pub expected_name: Option<String>,
|
||||
/// Replacement payload size.
|
||||
pub replacement_bytes: u64,
|
||||
/// BLAKE3 of the replacement payload.
|
||||
pub replacement_blake3: String,
|
||||
}
|
||||
|
||||
/// Rollback information recorded for a localized publication.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalizedPatchRollbackInfo {
|
||||
/// Previous `current` symlink target before this publication.
|
||||
pub previous_current_target: Option<PathBuf>,
|
||||
/// Version directory that should be removed when rolling this publication back.
|
||||
pub remove_version_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Integrity summary for a published localized release.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalizedPatchIntegrity {
|
||||
/// Number of changed files verified against the manifest.
|
||||
pub verified_changed_file_count: usize,
|
||||
/// Number of TextAsset operations recorded in the manifest.
|
||||
pub verified_text_asset_operation_count: usize,
|
||||
/// Whether `current` points at this localized release.
|
||||
pub current_points_to_release: bool,
|
||||
}
|
||||
|
||||
/// Persisted manifest for one localized release.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalizedPatchManifest {
|
||||
/// Manifest schema version.
|
||||
#[serde(default = "default_patch_manifest_version")]
|
||||
pub manifest_version: u32,
|
||||
/// Official release ID used as the patch source.
|
||||
pub official_release_id: String,
|
||||
/// Published localized release ID.
|
||||
pub localized_release_id: String,
|
||||
/// Manifest generation time as Unix seconds.
|
||||
pub generated_unix_seconds: u64,
|
||||
/// Changed file count.
|
||||
pub file_count: usize,
|
||||
/// TextAsset operation count.
|
||||
pub text_asset_operation_count: usize,
|
||||
/// Changed files and their before/after hashes.
|
||||
pub files: Vec<LocalizedPatchFile>,
|
||||
/// Rollback information for this release.
|
||||
pub rollback: LocalizedPatchRollbackInfo,
|
||||
}
|
||||
|
||||
impl LocalizedPatchManifest {
|
||||
/// Converts the localized TextAsset manifest to the generic patch manifest model.
|
||||
pub fn to_patch_manifest(&self) -> bat_patch::PatchManifest {
|
||||
bat_patch::PatchManifest {
|
||||
version: bat_patch::PATCH_MANIFEST_VERSION,
|
||||
patch_id: self.localized_release_id.clone(),
|
||||
source_version: self.official_release_id.clone(),
|
||||
target_version: self.localized_release_id.clone(),
|
||||
files: self
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| bat_patch::PatchManifestFile {
|
||||
path: PathBuf::from(&file.path),
|
||||
patch_kind: bat_patch::PatchKind::UnityFsTextAsset,
|
||||
source_blake3: file.original_blake3.clone(),
|
||||
target_blake3: file.localized_blake3.clone(),
|
||||
source_size: file.original_bytes,
|
||||
target_size: file.localized_bytes,
|
||||
})
|
||||
.collect(),
|
||||
rollback: bat_patch::PatchRollback {
|
||||
previous_current_target: self.rollback.previous_current_target.clone(),
|
||||
remove_target_path: Some(self.rollback.remove_version_path.clone()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a successful localized release publication.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
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,
|
||||
}
|
||||
|
||||
/// Applies TextAsset patches and atomically publishes a localized release.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LocalizedPatchService;
|
||||
|
||||
impl LocalizedPatchService {
|
||||
/// Creates the publisher.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Copies the official release, applies patches in staging and publishes it.
|
||||
pub fn publish(&self, config: &LocalizedPatchConfig) -> anyhow::Result<LocalizedPatchReport> {
|
||||
let staging = config
|
||||
.localized_output_root
|
||||
.join(LOCALIZED_STAGING_DIR)
|
||||
.join(&config.release_id);
|
||||
let version_path = config
|
||||
.localized_output_root
|
||||
.join(LOCALIZED_VERSIONS_DIR)
|
||||
.join(&config.release_id);
|
||||
let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
|
||||
let previous_current_target = current_symlink_target(¤t_path).ok().flatten();
|
||||
let version_existed_before = version_path.exists();
|
||||
match self.publish_inner(config, previous_current_target.clone()) {
|
||||
Ok(report) => Ok(report),
|
||||
Err(error) => {
|
||||
if let Err(rollback_error) = rollback_failed_publish(
|
||||
&config.localized_output_root,
|
||||
&staging,
|
||||
&version_path,
|
||||
!version_existed_before,
|
||||
¤t_path,
|
||||
previous_current_target.as_ref(),
|
||||
) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{error}; rollback failed: {rollback_error}"
|
||||
));
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.release_id);
|
||||
let version_path = config
|
||||
.localized_output_root
|
||||
.join(LOCALIZED_VERSIONS_DIR)
|
||||
.join(&config.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 patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE);
|
||||
|
||||
if version_path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"localized release already exists: {}",
|
||||
version_path.display()
|
||||
));
|
||||
}
|
||||
remove_owned_staging(&staging)?;
|
||||
fs::create_dir_all(&staging)?;
|
||||
copy_tree(&config.official_release_root, &staging)?;
|
||||
|
||||
let mut changed_files = Vec::with_capacity(config.patches.len());
|
||||
for operation in &config.patches {
|
||||
let target = staging.join(Path::new(&operation.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 = patch_unityfs_text_asset(&original, &operation.text_asset)
|
||||
.map_err(|error| anyhow::anyhow!("{}: {error}", operation.bundle_path))?;
|
||||
if original == patched {
|
||||
return Err(anyhow::anyhow!(
|
||||
"patch produced no change: {}",
|
||||
operation.bundle_path
|
||||
));
|
||||
}
|
||||
write_file_atomic(&target, &patched, STATE_FILE_MODE, "汉化 patch 输出")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let original_blake3 = blake3::hash(&original).to_hex().to_string();
|
||||
let localized_blake3 = blake3::hash(&patched).to_hex().to_string();
|
||||
changed_files.push(LocalizedPatchFile {
|
||||
path: operation.bundle_path.clone(),
|
||||
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: vec![LocalizedPatchOperation::from_text_asset_patch(
|
||||
&operation.text_asset,
|
||||
)],
|
||||
});
|
||||
}
|
||||
|
||||
let manifest = LocalizedPatchManifest {
|
||||
manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION,
|
||||
official_release_id: config.release_id.clone(),
|
||||
localized_release_id: config.release_id.clone(),
|
||||
generated_unix_seconds: unix_seconds_now(),
|
||||
file_count: changed_files.len(),
|
||||
text_asset_operation_count: changed_files
|
||||
.iter()
|
||||
.map(|file| file.text_asset_operations.len())
|
||||
.sum(),
|
||||
files: changed_files.clone(),
|
||||
rollback: LocalizedPatchRollbackInfo {
|
||||
previous_current_target: previous_current_target.clone(),
|
||||
remove_version_path: version_path.clone(),
|
||||
},
|
||||
};
|
||||
write_file_atomic(
|
||||
&staging.join(LOCALIZED_PATCH_MANIFEST_FILE),
|
||||
&serde_json::to_vec_pretty(&manifest)?,
|
||||
STATE_FILE_MODE,
|
||||
"汉化 patch manifest",
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
verify_patch_manifest_files(&config.official_release_root, &staging, &manifest)?;
|
||||
fs::create_dir_all(config.localized_output_root.join(LOCALIZED_VERSIONS_DIR))?;
|
||||
fs::rename(&staging, &version_path)?;
|
||||
switch_current_symlink(
|
||||
&config.localized_output_root,
|
||||
¤t_path,
|
||||
&config.release_id,
|
||||
)?;
|
||||
|
||||
let state = LocalizedVersionState {
|
||||
state_version: 1,
|
||||
official_release_id: config.release_id.clone(),
|
||||
current_release_id: Some(config.release_id.clone()),
|
||||
status: "localized".to_string(),
|
||||
updated_unix_seconds: unix_seconds_now(),
|
||||
};
|
||||
write_file_atomic(
|
||||
&state_path,
|
||||
&serde_json::to_vec_pretty(&state)?,
|
||||
STATE_FILE_MODE,
|
||||
"汉化版本状态",
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let integrity = verify_published_localized_release(
|
||||
&config.official_release_root,
|
||||
&version_path,
|
||||
¤t_path,
|
||||
)?;
|
||||
|
||||
Ok(LocalizedPatchReport {
|
||||
version_path,
|
||||
current_path,
|
||||
state_path,
|
||||
patch_manifest_path,
|
||||
files: changed_files,
|
||||
manifest,
|
||||
integrity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the localized release state without following a symlink at the file
|
||||
/// path. A missing state file means no localized release has been published.
|
||||
pub fn read_localized_version_state(
|
||||
localized_output_root: &Path,
|
||||
) -> anyhow::Result<Option<LocalizedVersionState>> {
|
||||
let path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE);
|
||||
let Some(bytes) = read_file_no_symlink(&path, "汉化版本状态").map_err(anyhow::Error::msg)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let state: LocalizedVersionState = serde_json::from_slice(&bytes)?;
|
||||
if state.state_version != 1 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"不支持的汉化版本状态 schema:{},当前版本=1",
|
||||
state.state_version
|
||||
));
|
||||
}
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
/// 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) -> Self {
|
||||
Self {
|
||||
serialized_file_path: patch.serialized_file_path.clone(),
|
||||
path_id: patch.path_id,
|
||||
expected_name: patch.expected_name.clone(),
|
||||
replacement_bytes: patch.replacement.len() as u64,
|
||||
replacement_blake3: blake3::hash(&patch.replacement).to_hex().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_published_localized_release(
|
||||
official_release_root: &Path,
|
||||
version_path: &Path,
|
||||
current_path: &Path,
|
||||
) -> anyhow::Result<LocalizedPatchIntegrity> {
|
||||
let manifest = read_localized_patch_manifest_at(version_path)?.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"缺少汉化 patch manifest:{}",
|
||||
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
|
||||
)
|
||||
})?;
|
||||
let mut integrity =
|
||||
verify_patch_manifest_files(official_release_root, version_path, &manifest)?;
|
||||
integrity.current_points_to_release = current_points_to_version(current_path, version_path)?;
|
||||
if !integrity.current_points_to_release {
|
||||
return Err(anyhow::anyhow!(
|
||||
"汉化 current 未指向发布版本:current={} version={}",
|
||||
current_path.display(),
|
||||
version_path.display()
|
||||
));
|
||||
}
|
||||
Ok(integrity)
|
||||
}
|
||||
|
||||
fn verify_patch_manifest_files(
|
||||
official_release_root: &Path,
|
||||
localized_release_root: &Path,
|
||||
manifest: &LocalizedPatchManifest,
|
||||
) -> anyhow::Result<LocalizedPatchIntegrity> {
|
||||
let mut operation_count = 0usize;
|
||||
for file in &manifest.files {
|
||||
let relative = Path::new(&file.path);
|
||||
let official_path = official_release_root.join(relative);
|
||||
let localized_path = localized_release_root.join(relative);
|
||||
ensure_path_within_root(official_release_root, &official_path)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
ensure_path_within_root(localized_release_root, &localized_path)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
ensure_safe_file_target(official_release_root, &official_path, "官方 patch 原文件")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
ensure_safe_file_target(localized_release_root, &localized_path, "汉化 patch 产物")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let original = fs::read(&official_path)?;
|
||||
let localized = fs::read(&localized_path)?;
|
||||
let original_hash = blake3::hash(&original).to_hex().to_string();
|
||||
let localized_hash = blake3::hash(&localized).to_hex().to_string();
|
||||
if original_hash != file.original_blake3 || original.len() as u64 != file.original_bytes {
|
||||
return Err(anyhow::anyhow!(
|
||||
"汉化 manifest 原文件校验失败 {}:期望 hash={} bytes={},实际 hash={} bytes={}",
|
||||
file.path,
|
||||
file.original_blake3,
|
||||
file.original_bytes,
|
||||
original_hash,
|
||||
original.len()
|
||||
));
|
||||
}
|
||||
if localized_hash != file.localized_blake3 || localized.len() as u64 != file.localized_bytes
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"汉化 manifest 产物校验失败 {}:期望 hash={} bytes={},实际 hash={} bytes={}",
|
||||
file.path,
|
||||
file.localized_blake3,
|
||||
file.localized_bytes,
|
||||
localized_hash,
|
||||
localized.len()
|
||||
));
|
||||
}
|
||||
if localized_hash == original_hash {
|
||||
return Err(anyhow::anyhow!(
|
||||
"汉化 manifest 文件未发生变化:{}",
|
||||
file.path
|
||||
));
|
||||
}
|
||||
operation_count += file.text_asset_operations.len();
|
||||
}
|
||||
if manifest.file_count != manifest.files.len() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"汉化 manifest file_count 不一致:声明 {},实际 {}",
|
||||
manifest.file_count,
|
||||
manifest.files.len()
|
||||
));
|
||||
}
|
||||
if manifest.text_asset_operation_count != operation_count {
|
||||
return Err(anyhow::anyhow!(
|
||||
"汉化 manifest operation_count 不一致:声明 {},实际 {}",
|
||||
manifest.text_asset_operation_count,
|
||||
operation_count
|
||||
));
|
||||
}
|
||||
Ok(LocalizedPatchIntegrity {
|
||||
verified_changed_file_count: manifest.files.len(),
|
||||
verified_text_asset_operation_count: operation_count,
|
||||
current_points_to_release: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn current_symlink_target(current_path: &Path) -> anyhow::Result<Option<PathBuf>> {
|
||||
match fs::symlink_metadata(current_path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => Ok(Some(fs::read_link(current_path)?)),
|
||||
Ok(_) => Err(anyhow::anyhow!(
|
||||
"汉化 current 已存在但不是 symlink:{}",
|
||||
current_path.display()
|
||||
)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn 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 rollback_failed_publish(
|
||||
localized_output_root: &Path,
|
||||
staging: &Path,
|
||||
version_path: &Path,
|
||||
remove_version_path: bool,
|
||||
current_path: &Path,
|
||||
previous_current_target: Option<&PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
remove_owned_path(staging)?;
|
||||
if remove_version_path {
|
||||
remove_owned_path(version_path)?;
|
||||
}
|
||||
remove_owned_path(&localized_output_root.join(".current.tmp"))?;
|
||||
remove_owned_path(&localized_output_root.join(".current.rollback.tmp"))?;
|
||||
let failed_target = version_path
|
||||
.file_name()
|
||||
.map(|release_id| Path::new(LOCALIZED_VERSIONS_DIR).join(release_id));
|
||||
if failed_target.as_ref().is_some_and(|target| {
|
||||
fs::read_link(current_path)
|
||||
.map(|current_target| current_target == *target)
|
||||
.unwrap_or(false)
|
||||
}) {
|
||||
restore_current_symlink(localized_output_root, current_path, previous_current_target)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_owned_path(path: &Path) -> anyhow::Result<()> {
|
||||
if let Ok(metadata) = fs::symlink_metadata(path) {
|
||||
if metadata.file_type().is_symlink() || metadata.is_file() {
|
||||
fs::remove_file(path)?;
|
||||
} else if metadata.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn restore_current_symlink(
|
||||
root: &Path,
|
||||
current_path: &Path,
|
||||
previous_current_target: Option<&PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
if let Some(previous_target) = previous_current_target {
|
||||
let temporary = root.join(".current.rollback.tmp");
|
||||
remove_owned_path(&temporary)?;
|
||||
symlink(previous_target, &temporary)?;
|
||||
fs::rename(temporary, current_path)?;
|
||||
} else if fs::symlink_metadata(current_path)
|
||||
.map(|metadata| metadata.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
fs::remove_file(current_path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn restore_current_symlink(
|
||||
_root: &Path,
|
||||
_current_path: &Path,
|
||||
_previous_current_target: Option<&PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_config(config: &LocalizedPatchConfig) -> Result<(), String> {
|
||||
let official = lexical_absolute(&config.official_release_root)?;
|
||||
let localized = lexical_absolute(&config.localized_output_root)?;
|
||||
if official == localized || official.starts_with(&localized) || localized.starts_with(&official)
|
||||
{
|
||||
return Err(format!(
|
||||
"官方 release 与汉化输出目录不能相同或互相嵌套:官方={} 汉化={}",
|
||||
official.display(),
|
||||
localized.display()
|
||||
));
|
||||
}
|
||||
if config.release_id.is_empty()
|
||||
|| config.release_id.contains('/')
|
||||
|| config.release_id.contains('\\')
|
||||
|| config.release_id == "."
|
||||
|| config.release_id == ".."
|
||||
{
|
||||
return Err(format!("非法汉化 release id:{}", config.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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn localized_manifest_converts_to_generic_patch_manifest() {
|
||||
let source = b"source";
|
||||
let target = b"target";
|
||||
let manifest = LocalizedPatchManifest {
|
||||
manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION,
|
||||
official_release_id: "official-v1".to_string(),
|
||||
localized_release_id: "localized-v1".to_string(),
|
||||
generated_unix_seconds: 123,
|
||||
file_count: 1,
|
||||
text_asset_operation_count: 1,
|
||||
files: vec![LocalizedPatchFile {
|
||||
path: "Bundles/file.bundle".to_string(),
|
||||
original_blake3: blake3::hash(source).to_hex().to_string(),
|
||||
localized_blake3: blake3::hash(target).to_hex().to_string(),
|
||||
original_bytes: source.len() as u64,
|
||||
localized_bytes: target.len() as u64,
|
||||
byte_delta: target.len() as i64 - source.len() as i64,
|
||||
text_asset_operations: vec![LocalizedPatchOperation {
|
||||
serialized_file_path: "CAB-asset".to_string(),
|
||||
path_id: 1,
|
||||
expected_name: Some("Text".to_string()),
|
||||
replacement_bytes: target.len() as u64,
|
||||
replacement_blake3: blake3::hash(target).to_hex().to_string(),
|
||||
}],
|
||||
}],
|
||||
rollback: LocalizedPatchRollbackInfo {
|
||||
previous_current_target: Some(PathBuf::from("versions/previous")),
|
||||
remove_version_path: PathBuf::from("versions/localized-v1"),
|
||||
},
|
||||
};
|
||||
|
||||
let patch_manifest = manifest.to_patch_manifest();
|
||||
|
||||
assert_eq!(patch_manifest.version, bat_patch::PATCH_MANIFEST_VERSION);
|
||||
assert_eq!(patch_manifest.patch_id, "localized-v1");
|
||||
assert_eq!(patch_manifest.source_version, "official-v1");
|
||||
assert_eq!(patch_manifest.target_version, "localized-v1");
|
||||
assert_eq!(patch_manifest.files.len(), 1);
|
||||
assert_eq!(
|
||||
patch_manifest.files[0].patch_kind,
|
||||
bat_patch::PatchKind::UnityFsTextAsset
|
||||
);
|
||||
assert_eq!(
|
||||
patch_manifest.rollback.previous_current_target,
|
||||
Some(PathBuf::from("versions/previous"))
|
||||
);
|
||||
assert_eq!(
|
||||
patch_manifest.rollback.remove_target_path,
|
||||
Some(PathBuf::from("versions/localized-v1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn publishes_a_separate_localized_release_atomically() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let official = temp.path().join("official-release");
|
||||
let localized = temp.path().join("localized");
|
||||
fs::create_dir_all(official.join("TableBundles")).unwrap();
|
||||
fs::write(
|
||||
official.join("TableBundles/TableCatalog.bytes"),
|
||||
b"official",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = LocalizedPatchService::new()
|
||||
.publish(&LocalizedPatchConfig::new(
|
||||
&official,
|
||||
&localized,
|
||||
"release-1",
|
||||
Vec::new(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read(report.version_path.join("TableBundles/TableCatalog.bytes")).unwrap(),
|
||||
b"official"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_link(report.current_path).unwrap(),
|
||||
PathBuf::from("versions/release-1")
|
||||
);
|
||||
let state: LocalizedVersionState =
|
||||
serde_json::from_slice(&fs::read(report.state_path).unwrap()).unwrap();
|
||||
assert_eq!(state.status, "localized");
|
||||
assert_eq!(state.current_release_id.as_deref(), Some("release-1"));
|
||||
assert!(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 failed_patch_publish_cleans_staging_and_unpublished_version() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let official = temp.path().join("official-release");
|
||||
let localized = temp.path().join("localized");
|
||||
fs::create_dir_all(official.join("Bundles")).unwrap();
|
||||
fs::write(official.join("Bundles/bad.bundle"), b"not-unityfs").unwrap();
|
||||
|
||||
let error = LocalizedPatchService::new()
|
||||
.publish(&LocalizedPatchConfig::new(
|
||||
&official,
|
||||
&localized,
|
||||
"release-1",
|
||||
vec![LocalizedTextAssetPatch {
|
||||
bundle_path: "Bundles/bad.bundle".to_string(),
|
||||
text_asset: TextAssetPatch::new("CAB-bad", 1, b"replacement".to_vec()),
|
||||
}],
|
||||
))
|
||||
.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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user