mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 12:14:56 +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,424 @@
|
||||
//! File-level Patch and UnityFS write operations.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bat_assetbundle::{
|
||||
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
||||
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::path_security::{
|
||||
ensure_safe_file_target, lexical_absolute, read_file_no_symlink, write_file_atomic,
|
||||
STATE_FILE_MODE,
|
||||
};
|
||||
|
||||
/// File patch algorithm selected by `patch.apply`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PatchApplyKind {
|
||||
/// Deterministic binary hunk patch.
|
||||
Binary,
|
||||
/// RFC 6902 JSON Patch.
|
||||
Json,
|
||||
/// UTF-8 Text Patch.
|
||||
Text,
|
||||
}
|
||||
|
||||
impl PatchApplyKind {
|
||||
/// Stable RPC/CLI label.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Binary => "binary",
|
||||
Self::Json => "json",
|
||||
Self::Text => "text",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for applying one file-level patch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PatchApplyParams {
|
||||
/// Patch algorithm.
|
||||
pub kind: PatchApplyKind,
|
||||
/// Source file path.
|
||||
pub source_path: PathBuf,
|
||||
/// Patch document path.
|
||||
pub patch_path: PathBuf,
|
||||
/// Target file path written atomically.
|
||||
pub target_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Parameters for patching one UnityFS TextAsset object.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UnityFsTextAssetPatchParams {
|
||||
/// Source UnityFS bundle file.
|
||||
pub bundle_path: PathBuf,
|
||||
/// Serialized file path inside the UnityFS directory table.
|
||||
pub serialized_file_path: String,
|
||||
/// Unity object path ID.
|
||||
pub path_id: i64,
|
||||
/// Replacement raw bytes path.
|
||||
pub replacement_path: PathBuf,
|
||||
/// Target bundle file path written atomically.
|
||||
pub target_path: PathBuf,
|
||||
/// Optional expected TextAsset name.
|
||||
#[serde(default)]
|
||||
pub expected_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for patching one TypeTree string field inside a UnityFS bundle.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UnityFsStringFieldPatchParams {
|
||||
/// Source UnityFS bundle file.
|
||||
pub bundle_path: PathBuf,
|
||||
/// Serialized file path inside the UnityFS directory table.
|
||||
pub serialized_file_path: String,
|
||||
/// Unity object path ID.
|
||||
pub path_id: i64,
|
||||
/// Stable TypeTree field path.
|
||||
pub field_path: String,
|
||||
/// Replacement string supplied inline.
|
||||
#[serde(default)]
|
||||
pub replacement_text: Option<String>,
|
||||
/// Replacement UTF-8 file path.
|
||||
#[serde(default)]
|
||||
pub replacement_path: Option<PathBuf>,
|
||||
/// Target bundle file path written atomically.
|
||||
pub target_path: PathBuf,
|
||||
/// Optional expected source string.
|
||||
#[serde(default)]
|
||||
pub expected_value: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for patching one semantic TypeTree field inside a UnityFS bundle.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UnityFsFieldPatchParams {
|
||||
/// Source UnityFS bundle file.
|
||||
pub bundle_path: PathBuf,
|
||||
/// Serialized file path inside the UnityFS directory table.
|
||||
pub serialized_file_path: String,
|
||||
/// Unity object path ID.
|
||||
pub path_id: i64,
|
||||
/// Stable TypeTree field path.
|
||||
pub field_path: String,
|
||||
/// Replacement value encoded according to the current TypeTree field type.
|
||||
pub replacement: UnitySerializedReplacementValue,
|
||||
/// Target bundle file path written atomically.
|
||||
pub target_path: PathBuf,
|
||||
/// Optional expected source value.
|
||||
#[serde(default)]
|
||||
pub expected_value: Option<UnitySerializedReplacementValue>,
|
||||
}
|
||||
|
||||
/// Result of a file-level patch operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct PatchApplyReport {
|
||||
/// RPC/CLI command name.
|
||||
pub command: &'static str,
|
||||
/// Operation status.
|
||||
pub status: &'static str,
|
||||
/// Human-readable summary.
|
||||
pub message: &'static str,
|
||||
/// Patch algorithm.
|
||||
pub kind: PatchApplyKind,
|
||||
/// Absolute source path.
|
||||
pub source_path: PathBuf,
|
||||
/// Absolute patch path.
|
||||
pub patch_path: PathBuf,
|
||||
/// Absolute target path.
|
||||
pub target_path: PathBuf,
|
||||
/// Source byte length.
|
||||
pub source_size: u64,
|
||||
/// Patch document byte length.
|
||||
pub patch_size: u64,
|
||||
/// Target byte length.
|
||||
pub target_size: u64,
|
||||
/// Source BLAKE3 hash.
|
||||
pub source_blake3: String,
|
||||
/// Patch document BLAKE3 hash.
|
||||
pub patch_blake3: String,
|
||||
/// Target BLAKE3 hash.
|
||||
pub target_blake3: String,
|
||||
}
|
||||
|
||||
/// Result of a UnityFS write operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct UnityFsPatchReport {
|
||||
/// RPC/CLI command name.
|
||||
pub command: &'static str,
|
||||
/// Operation status.
|
||||
pub status: &'static str,
|
||||
/// Human-readable summary.
|
||||
pub message: &'static str,
|
||||
/// Absolute source UnityFS bundle path.
|
||||
pub bundle_path: PathBuf,
|
||||
/// Serialized file path inside the UnityFS directory table.
|
||||
pub serialized_file_path: String,
|
||||
/// Unity object path ID.
|
||||
pub path_id: i64,
|
||||
/// Optional TypeTree field path for string-field patches.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub field_path: Option<String>,
|
||||
/// Absolute target bundle path.
|
||||
pub target_path: PathBuf,
|
||||
/// Source bundle byte length.
|
||||
pub source_size: u64,
|
||||
/// Replacement byte length.
|
||||
pub replacement_size: u64,
|
||||
/// Target bundle byte length.
|
||||
pub target_size: u64,
|
||||
/// Source bundle BLAKE3 hash.
|
||||
pub source_blake3: String,
|
||||
/// Replacement BLAKE3 hash.
|
||||
pub replacement_blake3: String,
|
||||
/// Target bundle BLAKE3 hash.
|
||||
pub target_blake3: String,
|
||||
}
|
||||
|
||||
/// Applies a Binary/JSON/Text patch document to one source file.
|
||||
pub fn apply_patch_file(params: &PatchApplyParams) -> anyhow::Result<PatchApplyReport> {
|
||||
let source_path = lexical_absolute(¶ms.source_path).map_err(anyhow::Error::msg)?;
|
||||
let patch_path = lexical_absolute(¶ms.patch_path).map_err(anyhow::Error::msg)?;
|
||||
let target_path = lexical_absolute(¶ms.target_path).map_err(anyhow::Error::msg)?;
|
||||
ensure_target_is_not_input(&target_path, &[&source_path, &patch_path])?;
|
||||
|
||||
let source = read_required_file(&source_path, "patch source")?;
|
||||
let patch = read_required_file(&patch_path, "patch document")?;
|
||||
let target = match params.kind {
|
||||
PatchApplyKind::Binary => bat_patch::binary::apply_patch(&source, &patch)?,
|
||||
PatchApplyKind::Json => {
|
||||
let source = std::str::from_utf8(&source)
|
||||
.map_err(|error| anyhow::anyhow!("JSON patch source 不是 UTF-8:{error}"))?;
|
||||
let patch = std::str::from_utf8(&patch)
|
||||
.map_err(|error| anyhow::anyhow!("JSON patch document 不是 UTF-8:{error}"))?;
|
||||
bat_patch::json::apply_json_patch(source, patch)?.into_bytes()
|
||||
}
|
||||
PatchApplyKind::Text => bat_patch::text::apply_patch_bytes(&source, &patch)?,
|
||||
};
|
||||
write_output_file(&target_path, &target, "patch target")?;
|
||||
|
||||
Ok(PatchApplyReport {
|
||||
command: "patch.apply",
|
||||
status: "patched",
|
||||
message: "patch 已应用并原子写入目标文件",
|
||||
kind: params.kind,
|
||||
source_path,
|
||||
patch_path,
|
||||
target_path,
|
||||
source_size: source.len() as u64,
|
||||
patch_size: patch.len() as u64,
|
||||
target_size: target.len() as u64,
|
||||
source_blake3: blake3_hex(&source),
|
||||
patch_blake3: blake3_hex(&patch),
|
||||
target_blake3: blake3_hex(&target),
|
||||
})
|
||||
}
|
||||
|
||||
/// Patches one UnityFS TextAsset and writes the rebuilt bundle atomically.
|
||||
pub fn apply_unityfs_text_asset_patch_file(
|
||||
params: &UnityFsTextAssetPatchParams,
|
||||
) -> anyhow::Result<UnityFsPatchReport> {
|
||||
let bundle_path = lexical_absolute(¶ms.bundle_path).map_err(anyhow::Error::msg)?;
|
||||
let replacement_path =
|
||||
lexical_absolute(¶ms.replacement_path).map_err(anyhow::Error::msg)?;
|
||||
let target_path = lexical_absolute(¶ms.target_path).map_err(anyhow::Error::msg)?;
|
||||
ensure_target_is_not_input(&target_path, &[&bundle_path, &replacement_path])?;
|
||||
|
||||
let bundle = read_required_file(&bundle_path, "UnityFS bundle")?;
|
||||
let replacement = read_required_file(&replacement_path, "TextAsset replacement")?;
|
||||
let mut patch = TextAssetPatch::new(
|
||||
params.serialized_file_path.clone(),
|
||||
params.path_id,
|
||||
replacement.clone(),
|
||||
);
|
||||
patch.expected_name = params.expected_name.clone();
|
||||
let target = patch_unityfs_text_asset(&bundle, &patch)?;
|
||||
write_output_file(&target_path, &target, "UnityFS target")?;
|
||||
|
||||
Ok(UnityFsPatchReport {
|
||||
command: "unityfs.patch_text_asset",
|
||||
status: "patched",
|
||||
message: "UnityFS TextAsset patch 已应用并原子写入目标 bundle",
|
||||
bundle_path,
|
||||
serialized_file_path: params.serialized_file_path.clone(),
|
||||
path_id: params.path_id,
|
||||
field_path: None,
|
||||
target_path,
|
||||
source_size: bundle.len() as u64,
|
||||
replacement_size: replacement.len() as u64,
|
||||
target_size: target.len() as u64,
|
||||
source_blake3: blake3_hex(&bundle),
|
||||
replacement_blake3: blake3_hex(&replacement),
|
||||
target_blake3: blake3_hex(&target),
|
||||
})
|
||||
}
|
||||
|
||||
/// Patches one TypeTree string field and writes the rebuilt UnityFS bundle atomically.
|
||||
pub fn apply_unityfs_string_field_patch_file(
|
||||
params: &UnityFsStringFieldPatchParams,
|
||||
) -> anyhow::Result<UnityFsPatchReport> {
|
||||
let bundle_path = lexical_absolute(¶ms.bundle_path).map_err(anyhow::Error::msg)?;
|
||||
let target_path = lexical_absolute(¶ms.target_path).map_err(anyhow::Error::msg)?;
|
||||
let replacement = replacement_text(params)?;
|
||||
let extra_inputs = params
|
||||
.replacement_path
|
||||
.as_ref()
|
||||
.map(|path| lexical_absolute(path).map_err(anyhow::Error::msg))
|
||||
.transpose()?;
|
||||
let mut inputs = vec![bundle_path.as_path()];
|
||||
if let Some(path) = extra_inputs.as_ref() {
|
||||
inputs.push(path.as_path());
|
||||
}
|
||||
ensure_target_is_not_input(&target_path, &inputs)?;
|
||||
|
||||
let bundle = read_required_file(&bundle_path, "UnityFS bundle")?;
|
||||
let patch = StringFieldPatch {
|
||||
serialized_file_path: params.serialized_file_path.clone(),
|
||||
path_id: params.path_id,
|
||||
field_path: params.field_path.clone(),
|
||||
expected_value: params.expected_value.clone(),
|
||||
replacement: replacement.clone(),
|
||||
};
|
||||
let target = patch_unityfs_string_field(&bundle, &patch)?;
|
||||
write_output_file(&target_path, &target, "UnityFS target")?;
|
||||
|
||||
Ok(UnityFsPatchReport {
|
||||
command: "unityfs.patch_string_field",
|
||||
status: "patched",
|
||||
message: "UnityFS TypeTree string field patch 已应用并原子写入目标 bundle",
|
||||
bundle_path,
|
||||
serialized_file_path: params.serialized_file_path.clone(),
|
||||
path_id: params.path_id,
|
||||
field_path: Some(params.field_path.clone()),
|
||||
target_path,
|
||||
source_size: bundle.len() as u64,
|
||||
replacement_size: replacement.len() as u64,
|
||||
target_size: target.len() as u64,
|
||||
source_blake3: blake3_hex(&bundle),
|
||||
replacement_blake3: blake3_hex(replacement.as_bytes()),
|
||||
target_blake3: blake3_hex(&target),
|
||||
})
|
||||
}
|
||||
|
||||
/// Patches one semantic TypeTree field and writes the rebuilt UnityFS bundle atomically.
|
||||
pub fn apply_unityfs_field_patch_file(
|
||||
params: &UnityFsFieldPatchParams,
|
||||
) -> anyhow::Result<UnityFsPatchReport> {
|
||||
let bundle_path = lexical_absolute(¶ms.bundle_path).map_err(anyhow::Error::msg)?;
|
||||
let target_path = lexical_absolute(¶ms.target_path).map_err(anyhow::Error::msg)?;
|
||||
ensure_target_is_not_input(&target_path, &[&bundle_path])?;
|
||||
|
||||
let bundle = read_required_file(&bundle_path, "UnityFS bundle")?;
|
||||
let replacement_size = serde_json::to_vec(¶ms.replacement)
|
||||
.map_err(anyhow::Error::from)?
|
||||
.len() as u64;
|
||||
let patch = FieldPatch {
|
||||
serialized_file_path: params.serialized_file_path.clone(),
|
||||
path_id: params.path_id,
|
||||
field_path: params.field_path.clone(),
|
||||
expected_value: params.expected_value.clone(),
|
||||
replacement: params.replacement.clone(),
|
||||
};
|
||||
let target = patch_unityfs_field(&bundle, &patch)?;
|
||||
write_output_file(&target_path, &target, "UnityFS target")?;
|
||||
|
||||
Ok(UnityFsPatchReport {
|
||||
command: "unityfs.patch_field",
|
||||
status: "patched",
|
||||
message: "UnityFS TypeTree field patch 已应用并原子写入目标 bundle",
|
||||
bundle_path,
|
||||
serialized_file_path: params.serialized_file_path.clone(),
|
||||
path_id: params.path_id,
|
||||
field_path: Some(params.field_path.clone()),
|
||||
target_path,
|
||||
source_size: bundle.len() as u64,
|
||||
replacement_size,
|
||||
target_size: target.len() as u64,
|
||||
source_blake3: blake3_hex(&bundle),
|
||||
replacement_blake3: blake3_hex(&serde_json::to_vec(¶ms.replacement)?),
|
||||
target_blake3: blake3_hex(&target),
|
||||
})
|
||||
}
|
||||
|
||||
fn replacement_text(params: &UnityFsStringFieldPatchParams) -> anyhow::Result<String> {
|
||||
match (¶ms.replacement_text, ¶ms.replacement_path) {
|
||||
(Some(_), Some(_)) => Err(anyhow::anyhow!(
|
||||
"replacement_text 和 replacement_path 只能指定一个"
|
||||
)),
|
||||
(Some(text), None) => Ok(text.clone()),
|
||||
(None, Some(path)) => {
|
||||
let path = lexical_absolute(path).map_err(anyhow::Error::msg)?;
|
||||
let bytes = read_required_file(&path, "string replacement")?;
|
||||
String::from_utf8(bytes)
|
||||
.map_err(|error| anyhow::anyhow!("string replacement 不是 UTF-8:{error}"))
|
||||
}
|
||||
(None, None) => Err(anyhow::anyhow!(
|
||||
"必须指定 replacement_text 或 replacement_path"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_required_file(path: &Path, label: &str) -> anyhow::Result<Vec<u8>> {
|
||||
read_file_no_symlink(path, label)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.ok_or_else(|| anyhow::anyhow!("{label} 不存在:{}", path.display()))
|
||||
}
|
||||
|
||||
fn write_output_file(path: &Path, bytes: &[u8], label: &str) -> anyhow::Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("{label} 缺少父目录:{}", path.display()))?;
|
||||
ensure_safe_file_target(parent, path, label).map_err(anyhow::Error::msg)?;
|
||||
write_file_atomic(path, bytes, STATE_FILE_MODE, label).map_err(anyhow::Error::msg)
|
||||
}
|
||||
|
||||
fn ensure_target_is_not_input(target: &Path, inputs: &[&Path]) -> anyhow::Result<()> {
|
||||
for input in inputs {
|
||||
if target == *input {
|
||||
return Err(anyhow::anyhow!(
|
||||
"target_path 不能与输入文件相同:{}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn blake3_hex(bytes: &[u8]) -> String {
|
||||
blake3::hash(bytes).to_hex().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn patch_apply_rejects_in_place_target() {
|
||||
let path = PathBuf::from("/tmp/source.bin");
|
||||
let params = PatchApplyParams {
|
||||
kind: PatchApplyKind::Binary,
|
||||
source_path: path.clone(),
|
||||
patch_path: PathBuf::from("/tmp/patch.json"),
|
||||
target_path: path,
|
||||
};
|
||||
let error = apply_patch_file(¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains("target_path 不能与输入文件相同"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_field_params_require_one_replacement_source() {
|
||||
let params = UnityFsStringFieldPatchParams {
|
||||
bundle_path: PathBuf::from("/tmp/source.bundle"),
|
||||
serialized_file_path: "CAB".to_string(),
|
||||
path_id: 1,
|
||||
field_path: "message".to_string(),
|
||||
replacement_text: Some("a".to_string()),
|
||||
replacement_path: Some(PathBuf::from("/tmp/replacement.txt")),
|
||||
target_path: PathBuf::from("/tmp/target.bundle"),
|
||||
expected_value: None,
|
||||
};
|
||||
let error = replacement_text(¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains("只能指定一个"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user