mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 08:54:55 +08:00
实现 Binary、JSON、Text patch 与通用 manifest 校验基础。 验证:cargo test -p bat-patch --locked;cargo clippy -p bat-patch --all-targets --locked -- -D warnings。
306 lines
10 KiB
Rust
306 lines
10 KiB
Rust
//! Deterministic UTF-8 text patch support.
|
|
|
|
use crate::PatchError;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Current text patch schema version.
|
|
pub const TEXT_PATCH_VERSION: u32 = 1;
|
|
|
|
/// UTF-8 text patch made of source-relative replacement ranges.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct TextPatch {
|
|
/// Patch schema version.
|
|
pub version: u32,
|
|
/// Expected BLAKE3 hash of the source UTF-8 bytes.
|
|
pub source_blake3: String,
|
|
/// Expected BLAKE3 hash of the target UTF-8 bytes.
|
|
pub target_blake3: String,
|
|
/// Source byte length.
|
|
pub source_size: u64,
|
|
/// Target byte length.
|
|
pub target_size: u64,
|
|
/// Ordered source-relative operations.
|
|
pub operations: Vec<TextPatchOperation>,
|
|
}
|
|
|
|
/// One source-relative text patch operation.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum TextPatchOperation {
|
|
/// Replaces a UTF-8 byte range in the original source text.
|
|
ReplaceRange {
|
|
/// Byte offset in the original source text.
|
|
offset: u64,
|
|
/// Number of source bytes to replace.
|
|
length: u64,
|
|
/// Optional text that must exactly match the source range.
|
|
expected: Option<String>,
|
|
/// Replacement text.
|
|
replacement: String,
|
|
},
|
|
}
|
|
|
|
/// Builds a deterministic one-hunk text patch from source and target text.
|
|
pub fn diff(source: &str, target: &str) -> TextPatch {
|
|
if source == target {
|
|
return TextPatch {
|
|
version: TEXT_PATCH_VERSION,
|
|
source_blake3: blake3_hex(source.as_bytes()),
|
|
target_blake3: blake3_hex(target.as_bytes()),
|
|
source_size: source.len() as u64,
|
|
target_size: target.len() as u64,
|
|
operations: Vec::new(),
|
|
};
|
|
}
|
|
|
|
let prefix = common_prefix_boundary(source, target);
|
|
let (source_suffix, target_suffix) = common_suffix_boundaries(source, target, prefix);
|
|
let operation = TextPatchOperation::ReplaceRange {
|
|
offset: prefix as u64,
|
|
length: (source_suffix - prefix) as u64,
|
|
expected: Some(source[prefix..source_suffix].to_string()),
|
|
replacement: target[prefix..target_suffix].to_string(),
|
|
};
|
|
TextPatch {
|
|
version: TEXT_PATCH_VERSION,
|
|
source_blake3: blake3_hex(source.as_bytes()),
|
|
target_blake3: blake3_hex(target.as_bytes()),
|
|
source_size: source.len() as u64,
|
|
target_size: target.len() as u64,
|
|
operations: vec![operation],
|
|
}
|
|
}
|
|
|
|
/// Builds a text patch from caller-provided source-relative operations.
|
|
pub fn from_operations(
|
|
source: &str,
|
|
operations: Vec<TextPatchOperation>,
|
|
) -> crate::Result<TextPatch> {
|
|
let target = apply_operations(source, &operations)?;
|
|
Ok(TextPatch {
|
|
version: TEXT_PATCH_VERSION,
|
|
source_blake3: blake3_hex(source.as_bytes()),
|
|
target_blake3: blake3_hex(target.as_bytes()),
|
|
source_size: source.len() as u64,
|
|
target_size: target.len() as u64,
|
|
operations,
|
|
})
|
|
}
|
|
|
|
/// Applies a structured text patch.
|
|
pub fn apply_text_patch(source: &str, patch: &TextPatch) -> crate::Result<String> {
|
|
if patch.version != TEXT_PATCH_VERSION {
|
|
return Err(PatchError::ApplyFailed(format!(
|
|
"unsupported text patch version {}",
|
|
patch.version
|
|
)));
|
|
}
|
|
if patch.source_size != source.len() as u64
|
|
|| patch.source_blake3 != blake3_hex(source.as_bytes())
|
|
{
|
|
return Err(PatchError::ApplyFailed(
|
|
"text patch source integrity mismatch".to_string(),
|
|
));
|
|
}
|
|
|
|
let output = apply_operations(source, &patch.operations)?;
|
|
if output.len() as u64 != patch.target_size
|
|
|| patch.target_blake3 != blake3_hex(output.as_bytes())
|
|
{
|
|
return Err(PatchError::ApplyFailed(
|
|
"text patch target integrity mismatch".to_string(),
|
|
));
|
|
}
|
|
Ok(output)
|
|
}
|
|
|
|
/// Parses and applies a JSON-encoded text patch to a UTF-8 string.
|
|
pub fn apply_patch(source: &str, patch: &str) -> crate::Result<String> {
|
|
let patch: TextPatch = serde_json::from_str(patch)
|
|
.map_err(|error| PatchError::ApplyFailed(format!("invalid text patch JSON: {error}")))?;
|
|
apply_text_patch(source, &patch)
|
|
}
|
|
|
|
/// Parses and applies a JSON-encoded text patch to UTF-8 bytes.
|
|
pub fn apply_patch_bytes(source: &[u8], patch: &[u8]) -> crate::Result<Vec<u8>> {
|
|
let source = std::str::from_utf8(source)
|
|
.map_err(|error| PatchError::ApplyFailed(format!("source is not UTF-8: {error}")))?;
|
|
let patch = std::str::from_utf8(patch)
|
|
.map_err(|error| PatchError::ApplyFailed(format!("patch is not UTF-8: {error}")))?;
|
|
Ok(apply_patch(source, patch)?.into_bytes())
|
|
}
|
|
|
|
fn apply_operations(source: &str, operations: &[TextPatchOperation]) -> crate::Result<String> {
|
|
let mut output = String::with_capacity(source.len());
|
|
let mut cursor = 0usize;
|
|
for operation in operations {
|
|
let (offset, length, expected, replacement) = match operation {
|
|
TextPatchOperation::ReplaceRange {
|
|
offset,
|
|
length,
|
|
expected,
|
|
replacement,
|
|
} => (*offset, *length, expected, replacement),
|
|
};
|
|
let start = usize::try_from(offset)
|
|
.map_err(|_| PatchError::ApplyFailed("text patch offset overflow".to_string()))?;
|
|
let length = usize::try_from(length)
|
|
.map_err(|_| PatchError::ApplyFailed("text patch length overflow".to_string()))?;
|
|
if start < cursor {
|
|
return Err(PatchError::ApplyFailed(format!(
|
|
"text patch operation at {start} overlaps previous range ending at {cursor}"
|
|
)));
|
|
}
|
|
let end = start
|
|
.checked_add(length)
|
|
.ok_or_else(|| PatchError::ApplyFailed("text patch range overflow".to_string()))?;
|
|
let replaced = source.get(start..end).ok_or_else(|| {
|
|
PatchError::ApplyFailed(format!(
|
|
"text patch range {start}..{end} is outside the source or not UTF-8 aligned"
|
|
))
|
|
})?;
|
|
if let Some(expected) = expected {
|
|
if replaced != expected {
|
|
return Err(PatchError::ApplyFailed(format!(
|
|
"text patch expected mismatch at {start}..{end}"
|
|
)));
|
|
}
|
|
}
|
|
output.push_str(&source[cursor..start]);
|
|
output.push_str(replacement);
|
|
cursor = end;
|
|
}
|
|
output.push_str(&source[cursor..]);
|
|
Ok(output)
|
|
}
|
|
|
|
fn common_prefix_boundary(source: &str, target: &str) -> usize {
|
|
let mut prefix = 0usize;
|
|
for ((source_index, source_char), (target_index, target_char)) in
|
|
source.char_indices().zip(target.char_indices())
|
|
{
|
|
if source_index != target_index || source_char != target_char {
|
|
break;
|
|
}
|
|
prefix = source_index + source_char.len_utf8();
|
|
}
|
|
prefix
|
|
}
|
|
|
|
fn common_suffix_boundaries(source: &str, target: &str, prefix: usize) -> (usize, usize) {
|
|
let mut source_suffix = source.len();
|
|
let mut target_suffix = target.len();
|
|
let mut source_chars = source[prefix..].char_indices().rev();
|
|
let mut target_chars = target[prefix..].char_indices().rev();
|
|
while let (Some((source_index, source_char)), Some((target_index, target_char))) =
|
|
(source_chars.next(), target_chars.next())
|
|
{
|
|
if source_char != target_char {
|
|
break;
|
|
}
|
|
source_suffix = prefix + source_index;
|
|
target_suffix = prefix + target_index;
|
|
}
|
|
(source_suffix, target_suffix)
|
|
}
|
|
|
|
fn blake3_hex(bytes: &[u8]) -> String {
|
|
blake3::hash(bytes).to_hex().to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn text_patch_round_trips_unicode_change() {
|
|
let source = "先生、こんにちは\nAbydos";
|
|
let target = "老师、你好\nAbydos";
|
|
let patch = diff(source, target);
|
|
let patch_json = serde_json::to_string(&patch).unwrap();
|
|
|
|
assert_eq!(apply_text_patch(source, &patch).unwrap(), target);
|
|
assert_eq!(apply_patch(source, &patch_json).unwrap(), target);
|
|
assert_eq!(
|
|
apply_patch_bytes(source.as_bytes(), patch_json.as_bytes()).unwrap(),
|
|
target.as_bytes()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn text_patch_applies_multiple_source_relative_ranges() {
|
|
let source = "alpha beta gamma";
|
|
let patch = from_operations(
|
|
source,
|
|
vec![
|
|
TextPatchOperation::ReplaceRange {
|
|
offset: 0,
|
|
length: 5,
|
|
expected: Some("alpha".to_string()),
|
|
replacement: "one".to_string(),
|
|
},
|
|
TextPatchOperation::ReplaceRange {
|
|
offset: 11,
|
|
length: 5,
|
|
expected: Some("gamma".to_string()),
|
|
replacement: "three".to_string(),
|
|
},
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(apply_text_patch(source, &patch).unwrap(), "one beta three");
|
|
}
|
|
|
|
#[test]
|
|
fn text_patch_rejects_expected_mismatch() {
|
|
let source = "alpha beta";
|
|
let operation = TextPatchOperation::ReplaceRange {
|
|
offset: 0,
|
|
length: 5,
|
|
expected: Some("wrong".to_string()),
|
|
replacement: "one".to_string(),
|
|
};
|
|
|
|
let error = from_operations(source, vec![operation]).unwrap_err();
|
|
|
|
assert!(matches!(error, PatchError::ApplyFailed(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn text_patch_rejects_overlapping_ranges() {
|
|
let source = "alpha beta";
|
|
let operation_a = TextPatchOperation::ReplaceRange {
|
|
offset: 0,
|
|
length: 5,
|
|
expected: None,
|
|
replacement: "one".to_string(),
|
|
};
|
|
let operation_b = TextPatchOperation::ReplaceRange {
|
|
offset: 3,
|
|
length: 2,
|
|
expected: None,
|
|
replacement: "two".to_string(),
|
|
};
|
|
|
|
let error = from_operations(source, vec![operation_a, operation_b]).unwrap_err();
|
|
|
|
assert!(matches!(error, PatchError::ApplyFailed(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn text_patch_rejects_non_boundary_range() {
|
|
let source = "éclair";
|
|
let operation = TextPatchOperation::ReplaceRange {
|
|
offset: 1,
|
|
length: 1,
|
|
expected: None,
|
|
replacement: "e".to_string(),
|
|
};
|
|
|
|
let error = from_operations(source, vec![operation]).unwrap_err();
|
|
|
|
assert!(matches!(error, PatchError::ApplyFailed(_)));
|
|
}
|
|
}
|