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:
2026-07-31 00:38:45 +08:00
parent f4880a71bd
commit 2079c6a307
25 changed files with 17287 additions and 225 deletions
+857
View File
@@ -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(&current_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,
&current_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,
&current_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,
&current_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());
}
}