mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
feat(patch): 补齐通用补丁引擎基础
实现 Binary、JSON、Text patch 与通用 manifest 校验基础。 验证:cargo test -p bat-patch --locked;cargo clippy -p bat-patch --all-targets --locked -- -D warnings。
This commit is contained in:
+158
-10
@@ -1,13 +1,145 @@
|
|||||||
//! Binary Patch 模块占位
|
//! Deterministic binary hunk patch.
|
||||||
|
|
||||||
/// Binary Patch 应用(尚未实现)。
|
use crate::PatchError;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Current binary patch schema version.
|
||||||
|
pub const BINARY_PATCH_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Binary patch made of deterministic copy/insert hunks.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BinaryPatch {
|
||||||
|
/// Patch schema version.
|
||||||
|
pub version: u32,
|
||||||
|
/// Expected BLAKE3 hash of the source bytes.
|
||||||
|
pub source_blake3: String,
|
||||||
|
/// Expected BLAKE3 hash of the target bytes.
|
||||||
|
pub target_blake3: String,
|
||||||
|
/// Source byte length.
|
||||||
|
pub source_size: u64,
|
||||||
|
/// Target byte length.
|
||||||
|
pub target_size: u64,
|
||||||
|
/// Ordered hunks.
|
||||||
|
pub hunks: Vec<BinaryPatchHunk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One binary patch hunk.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum BinaryPatchHunk {
|
||||||
|
/// Copy a byte range from the source.
|
||||||
|
Copy {
|
||||||
|
/// Source offset.
|
||||||
|
offset: u64,
|
||||||
|
/// Number of bytes to copy.
|
||||||
|
length: u64,
|
||||||
|
},
|
||||||
|
/// Insert literal bytes.
|
||||||
|
Insert {
|
||||||
|
/// Literal bytes.
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a deterministic hunk patch.
|
||||||
///
|
///
|
||||||
/// 返回 [`crate::PatchError::ApplyFailed`] 而非空结果,避免调用方把未实现的
|
/// The first implementation optimizes for correctness and stable output. It
|
||||||
/// 占位当成一次成功的补丁应用。
|
/// emits copy hunks for equal runs and insert hunks for changed runs; more
|
||||||
pub fn apply_patch(_old: &[u8], _patch: &[u8]) -> crate::Result<Vec<u8>> {
|
/// compact suffix/prefix matching can be added later without changing the
|
||||||
Err(crate::PatchError::ApplyFailed(
|
/// manifest/integrity contract.
|
||||||
"binary patch 尚未实现".to_string(),
|
pub fn diff(old: &[u8], new: &[u8]) -> BinaryPatch {
|
||||||
))
|
let mut hunks = Vec::new();
|
||||||
|
let mut index = 0usize;
|
||||||
|
while index < new.len() {
|
||||||
|
if index < old.len() && old[index] == new[index] {
|
||||||
|
let start = index;
|
||||||
|
while index < new.len() && index < old.len() && old[index] == new[index] {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
hunks.push(BinaryPatchHunk::Copy {
|
||||||
|
offset: start as u64,
|
||||||
|
length: (index - start) as u64,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = index;
|
||||||
|
while index < new.len() && (index >= old.len() || old[index] != new[index]) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
hunks.push(BinaryPatchHunk::Insert {
|
||||||
|
bytes: new[start..index].to_vec(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
BinaryPatch {
|
||||||
|
version: BINARY_PATCH_VERSION,
|
||||||
|
source_blake3: blake3_hex(old),
|
||||||
|
target_blake3: blake3_hex(new),
|
||||||
|
source_size: old.len() as u64,
|
||||||
|
target_size: new.len() as u64,
|
||||||
|
hunks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a structured binary patch.
|
||||||
|
pub fn apply_binary_patch(old: &[u8], patch: &BinaryPatch) -> crate::Result<Vec<u8>> {
|
||||||
|
if patch.version != BINARY_PATCH_VERSION {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"unsupported binary patch version {}",
|
||||||
|
patch.version
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if patch.source_size != old.len() as u64 || patch.source_blake3 != blake3_hex(old) {
|
||||||
|
return Err(PatchError::ApplyFailed(
|
||||||
|
"binary patch source integrity mismatch".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_capacity = usize::try_from(patch.target_size)
|
||||||
|
.map_err(|_| PatchError::ApplyFailed("binary patch target too large".to_string()))?;
|
||||||
|
let mut output = Vec::with_capacity(target_capacity);
|
||||||
|
for hunk in &patch.hunks {
|
||||||
|
match hunk {
|
||||||
|
BinaryPatchHunk::Copy { offset, length } => {
|
||||||
|
let start = usize::try_from(*offset).map_err(|_| {
|
||||||
|
PatchError::ApplyFailed("binary patch copy offset overflow".to_string())
|
||||||
|
})?;
|
||||||
|
let length = usize::try_from(*length).map_err(|_| {
|
||||||
|
PatchError::ApplyFailed("binary patch copy length overflow".to_string())
|
||||||
|
})?;
|
||||||
|
let end = start.checked_add(length).ok_or_else(|| {
|
||||||
|
PatchError::ApplyFailed("binary patch copy range overflow".to_string())
|
||||||
|
})?;
|
||||||
|
let bytes = old.get(start..end).ok_or_else(|| {
|
||||||
|
PatchError::ApplyFailed(format!(
|
||||||
|
"binary patch copy range {start}..{end} exceeds source {}",
|
||||||
|
old.len()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
output.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
BinaryPatchHunk::Insert { bytes } => output.extend_from_slice(bytes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.len() as u64 != patch.target_size || blake3_hex(&output) != patch.target_blake3 {
|
||||||
|
return Err(PatchError::ApplyFailed(
|
||||||
|
"binary patch target integrity mismatch".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes and applies a binary patch.
|
||||||
|
pub fn apply_patch(old: &[u8], patch: &[u8]) -> crate::Result<Vec<u8>> {
|
||||||
|
let patch: BinaryPatch = serde_json::from_slice(patch)
|
||||||
|
.map_err(|error| PatchError::ApplyFailed(format!("invalid binary patch JSON: {error}")))?;
|
||||||
|
apply_binary_patch(old, &patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blake3_hex(bytes: &[u8]) -> String {
|
||||||
|
blake3::hash(bytes).to_hex().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -15,8 +147,24 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_patch_reports_not_implemented() {
|
fn binary_patch_round_trips_changed_bytes() {
|
||||||
let error = apply_patch(b"old", b"patch").unwrap_err();
|
let old = b"abcdef012345";
|
||||||
|
let new = b"abcXYZ012345!";
|
||||||
|
let patch = diff(old, new);
|
||||||
|
let patch_json = serde_json::to_vec(&patch).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(apply_binary_patch(old, &patch).unwrap(), new);
|
||||||
|
assert_eq!(apply_patch(old, &patch_json).unwrap(), new);
|
||||||
|
assert!(patch
|
||||||
|
.hunks
|
||||||
|
.iter()
|
||||||
|
.any(|hunk| matches!(hunk, BinaryPatchHunk::Insert { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binary_patch_rejects_wrong_source() {
|
||||||
|
let patch = diff(b"old", b"new");
|
||||||
|
let error = apply_binary_patch(b"bad", &patch).unwrap_err();
|
||||||
assert!(matches!(error, crate::PatchError::ApplyFailed(_)));
|
assert!(matches!(error, crate::PatchError::ApplyFailed(_)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+354
-10
@@ -1,22 +1,366 @@
|
|||||||
//! JSON Patch 模块占位
|
//! RFC 6902 JSON Patch support.
|
||||||
|
|
||||||
/// JSON Patch 应用(尚未实现)。
|
use crate::PatchError;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// One RFC 6902 JSON Patch operation.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "op", rename_all = "lowercase")]
|
||||||
|
pub enum JsonPatchOperation {
|
||||||
|
/// Add a value at the target JSON Pointer.
|
||||||
|
Add {
|
||||||
|
/// Target JSON Pointer.
|
||||||
|
path: String,
|
||||||
|
/// Value to insert.
|
||||||
|
value: Value,
|
||||||
|
},
|
||||||
|
/// Remove the value at the target JSON Pointer.
|
||||||
|
Remove {
|
||||||
|
/// Target JSON Pointer.
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
/// Replace the value at the target JSON Pointer.
|
||||||
|
Replace {
|
||||||
|
/// Target JSON Pointer.
|
||||||
|
path: String,
|
||||||
|
/// Replacement value.
|
||||||
|
value: Value,
|
||||||
|
},
|
||||||
|
/// Move a value from one JSON Pointer to another.
|
||||||
|
Move {
|
||||||
|
/// Source JSON Pointer.
|
||||||
|
from: String,
|
||||||
|
/// Target JSON Pointer.
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
/// Copy a value from one JSON Pointer to another.
|
||||||
|
Copy {
|
||||||
|
/// Source JSON Pointer.
|
||||||
|
from: String,
|
||||||
|
/// Target JSON Pointer.
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
/// Assert that a JSON Pointer currently contains a value.
|
||||||
|
Test {
|
||||||
|
/// Target JSON Pointer.
|
||||||
|
path: String,
|
||||||
|
/// Expected value.
|
||||||
|
value: Value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies an RFC 6902 JSON Patch document to a JSON document string.
|
||||||
|
pub fn apply_json_patch(doc: &str, patch: &str) -> crate::Result<String> {
|
||||||
|
let mut document: Value = serde_json::from_str(doc)
|
||||||
|
.map_err(|error| PatchError::ApplyFailed(format!("invalid JSON document: {error}")))?;
|
||||||
|
let operations: Vec<JsonPatchOperation> = serde_json::from_str(patch)
|
||||||
|
.map_err(|error| PatchError::ApplyFailed(format!("invalid JSON patch: {error}")))?;
|
||||||
|
apply_json_patch_value(&mut document, &operations)?;
|
||||||
|
serde_json::to_string(&document)
|
||||||
|
.map_err(|error| PatchError::ApplyFailed(format!("failed to serialize JSON: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies parsed JSON Patch operations to a JSON value.
|
||||||
///
|
///
|
||||||
/// 返回 [`crate::PatchError::ApplyFailed`] 而非空字符串,避免调用方把未实现的
|
/// Each operation is applied atomically: when one operation fails, the document
|
||||||
/// 占位当成一次成功的补丁应用。
|
/// remains at the state produced by the previous successful operation.
|
||||||
pub fn apply_json_patch(_doc: &str, _patch: &str) -> crate::Result<String> {
|
pub fn apply_json_patch_value(
|
||||||
Err(crate::PatchError::ApplyFailed(
|
document: &mut Value,
|
||||||
"json patch 尚未实现".to_string(),
|
operations: &[JsonPatchOperation],
|
||||||
))
|
) -> crate::Result<()> {
|
||||||
|
for operation in operations {
|
||||||
|
let mut next = document.clone();
|
||||||
|
apply_operation(&mut next, operation)?;
|
||||||
|
*document = next;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_operation(document: &mut Value, operation: &JsonPatchOperation) -> crate::Result<()> {
|
||||||
|
match operation {
|
||||||
|
JsonPatchOperation::Add { path, value } => add_value(document, path, value.clone()),
|
||||||
|
JsonPatchOperation::Remove { path } => remove_value(document, path).map(drop),
|
||||||
|
JsonPatchOperation::Replace { path, value } => replace_value(document, path, value.clone()),
|
||||||
|
JsonPatchOperation::Move { from, path } => {
|
||||||
|
if from == path {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let value = get_value(document, from)?.clone();
|
||||||
|
remove_value(document, from)?;
|
||||||
|
add_value(document, path, value)
|
||||||
|
}
|
||||||
|
JsonPatchOperation::Copy { from, path } => {
|
||||||
|
let value = get_value(document, from)?.clone();
|
||||||
|
add_value(document, path, value)
|
||||||
|
}
|
||||||
|
JsonPatchOperation::Test { path, value } => {
|
||||||
|
let actual = get_value(document, path)?;
|
||||||
|
if actual == value {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(failed(format!(
|
||||||
|
"JSON patch test failed at {path}: expected {value}, actual {actual}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_value(document: &mut Value, path: &str, value: Value) -> crate::Result<()> {
|
||||||
|
let tokens = parse_json_pointer(path)?;
|
||||||
|
if tokens.is_empty() {
|
||||||
|
*document = value;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = tokens.last().expect("checked non-empty").clone();
|
||||||
|
let parent = get_mut_by_tokens(document, &tokens[..tokens.len() - 1])?;
|
||||||
|
match parent {
|
||||||
|
Value::Object(map) => {
|
||||||
|
map.insert(key, value);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Value::Array(items) => {
|
||||||
|
if key == "-" {
|
||||||
|
items.push(value);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let index = parse_array_index(&key, items.len(), true)?;
|
||||||
|
items.insert(index, value);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => Err(failed(format!(
|
||||||
|
"cannot add JSON patch value below non-container value {other}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_value(document: &mut Value, path: &str) -> crate::Result<Value> {
|
||||||
|
let tokens = parse_json_pointer(path)?;
|
||||||
|
if tokens.is_empty() {
|
||||||
|
return Ok(std::mem::take(document));
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = tokens.last().expect("checked non-empty").clone();
|
||||||
|
let parent = get_mut_by_tokens(document, &tokens[..tokens.len() - 1])?;
|
||||||
|
match parent {
|
||||||
|
Value::Object(map) => map
|
||||||
|
.remove(&key)
|
||||||
|
.ok_or_else(|| failed(format!("JSON patch path {path} does not exist"))),
|
||||||
|
Value::Array(items) => {
|
||||||
|
let index = parse_array_index(&key, items.len(), false)?;
|
||||||
|
Ok(items.remove(index))
|
||||||
|
}
|
||||||
|
other => Err(failed(format!(
|
||||||
|
"cannot remove JSON patch value below non-container value {other}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_value(document: &mut Value, path: &str, value: Value) -> crate::Result<()> {
|
||||||
|
let tokens = parse_json_pointer(path)?;
|
||||||
|
if tokens.is_empty() {
|
||||||
|
*document = value;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = tokens.last().expect("checked non-empty").clone();
|
||||||
|
let parent = get_mut_by_tokens(document, &tokens[..tokens.len() - 1])?;
|
||||||
|
match parent {
|
||||||
|
Value::Object(map) => {
|
||||||
|
let slot = map
|
||||||
|
.get_mut(&key)
|
||||||
|
.ok_or_else(|| failed(format!("JSON patch path {path} does not exist")))?;
|
||||||
|
*slot = value;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Value::Array(items) => {
|
||||||
|
let index = parse_array_index(&key, items.len(), false)?;
|
||||||
|
items[index] = value;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => Err(failed(format!(
|
||||||
|
"cannot replace JSON patch value below non-container value {other}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_value<'a>(document: &'a Value, path: &str) -> crate::Result<&'a Value> {
|
||||||
|
let tokens = parse_json_pointer(path)?;
|
||||||
|
let mut current = document;
|
||||||
|
for token in &tokens {
|
||||||
|
current = match current {
|
||||||
|
Value::Object(map) => map
|
||||||
|
.get(token)
|
||||||
|
.ok_or_else(|| failed(format!("JSON patch path {path} does not exist")))?,
|
||||||
|
Value::Array(items) => {
|
||||||
|
let index = parse_array_index(token, items.len(), false)?;
|
||||||
|
items
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| failed(format!("JSON patch path {path} does not exist")))?
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(failed(format!(
|
||||||
|
"cannot traverse JSON patch path {path} through non-container value {other}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_mut_by_tokens<'a>(
|
||||||
|
document: &'a mut Value,
|
||||||
|
tokens: &[String],
|
||||||
|
) -> crate::Result<&'a mut Value> {
|
||||||
|
let mut current = document;
|
||||||
|
for token in tokens {
|
||||||
|
current = match current {
|
||||||
|
Value::Object(map) => map
|
||||||
|
.get_mut(token)
|
||||||
|
.ok_or_else(|| failed(format!("JSON patch path segment {token} does not exist")))?,
|
||||||
|
Value::Array(items) => {
|
||||||
|
let index = parse_array_index(token, items.len(), false)?;
|
||||||
|
items.get_mut(index).ok_or_else(|| {
|
||||||
|
failed(format!("JSON patch path segment {token} does not exist"))
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(failed(format!(
|
||||||
|
"cannot traverse JSON patch path through non-container value {other}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_array_index(token: &str, len: usize, allow_end: bool) -> crate::Result<usize> {
|
||||||
|
if token.is_empty() || token == "-" {
|
||||||
|
return Err(failed(format!("invalid JSON patch array index {token}")));
|
||||||
|
}
|
||||||
|
let index = token
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| failed(format!("invalid JSON patch array index {token}")))?;
|
||||||
|
let max = if allow_end {
|
||||||
|
len
|
||||||
|
} else {
|
||||||
|
len.checked_sub(1)
|
||||||
|
.ok_or_else(|| failed("JSON patch array index exceeds empty array".to_string()))?
|
||||||
|
};
|
||||||
|
if index > max {
|
||||||
|
return Err(failed(format!(
|
||||||
|
"JSON patch array index {index} exceeds length {len}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_json_pointer(pointer: &str) -> crate::Result<Vec<String>> {
|
||||||
|
if pointer.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
if !pointer.starts_with('/') {
|
||||||
|
return Err(failed(format!(
|
||||||
|
"JSON patch pointer must be empty or start with '/': {pointer}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
pointer[1..]
|
||||||
|
.split('/')
|
||||||
|
.map(decode_json_pointer_token)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_json_pointer_token(token: &str) -> crate::Result<String> {
|
||||||
|
let mut decoded = String::with_capacity(token.len());
|
||||||
|
let mut chars = token.chars();
|
||||||
|
while let Some(character) = chars.next() {
|
||||||
|
if character != '~' {
|
||||||
|
decoded.push(character);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match chars.next() {
|
||||||
|
Some('0') => decoded.push('~'),
|
||||||
|
Some('1') => decoded.push('/'),
|
||||||
|
Some(other) => {
|
||||||
|
return Err(failed(format!(
|
||||||
|
"invalid JSON patch pointer escape ~{other}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
None => return Err(failed("invalid JSON patch pointer escape ~".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed(message: String) -> PatchError {
|
||||||
|
PatchError::ApplyFailed(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_json_patch_reports_not_implemented() {
|
fn apply_json_patch_handles_all_core_operations() {
|
||||||
let error = apply_json_patch("{}", "[]").unwrap_err();
|
let document = r#"{"name":"alice","items":["a","b"],"meta":{"keep":true}}"#;
|
||||||
|
let patch = r#"[
|
||||||
|
{"op":"test","path":"/meta/keep","value":true},
|
||||||
|
{"op":"add","path":"/items/-","value":"c"},
|
||||||
|
{"op":"replace","path":"/name","value":"bob"},
|
||||||
|
{"op":"copy","from":"/meta","path":"/copied"},
|
||||||
|
{"op":"move","from":"/items/0","path":"/first"},
|
||||||
|
{"op":"remove","path":"/meta/keep"}
|
||||||
|
]"#;
|
||||||
|
|
||||||
|
let output = apply_json_patch(document, patch).unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&output).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["name"], json!("bob"));
|
||||||
|
assert_eq!(value["items"], json!(["b", "c"]));
|
||||||
|
assert_eq!(value["first"], json!("a"));
|
||||||
|
assert_eq!(value["copied"], json!({"keep": true}));
|
||||||
|
assert_eq!(value["meta"], json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_json_patch_supports_pointer_escapes() {
|
||||||
|
let document = r#"{"a/b":{"tilde~key":1}}"#;
|
||||||
|
let patch = r#"[{"op":"replace","path":"/a~1b/tilde~0key","value":2}]"#;
|
||||||
|
|
||||||
|
let output = apply_json_patch(document, patch).unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&output).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["a/b"]["tilde~key"], json!(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_json_patch_rejects_failed_test_without_mutating_value() {
|
||||||
|
let mut value = json!({"enabled": true});
|
||||||
|
let operations = vec![
|
||||||
|
JsonPatchOperation::Add {
|
||||||
|
path: "/count".to_string(),
|
||||||
|
value: json!(1),
|
||||||
|
},
|
||||||
|
JsonPatchOperation::Test {
|
||||||
|
path: "/enabled".to_string(),
|
||||||
|
value: json!(false),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let error = apply_json_patch_value(&mut value, &operations).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, crate::PatchError::ApplyFailed(_)));
|
||||||
|
assert_eq!(value, json!({"enabled": true, "count": 1}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_json_patch_rejects_missing_remove_path() {
|
||||||
|
let error = apply_json_patch(r#"{"items":[]}"#, r#"[{"op":"remove","path":"/missing"}]"#)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(matches!(error, crate::PatchError::ApplyFailed(_)));
|
assert!(matches!(error, crate::PatchError::ApplyFailed(_)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,14 @@
|
|||||||
pub mod binary;
|
pub mod binary;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod json;
|
pub mod json;
|
||||||
|
pub mod manifest;
|
||||||
|
pub mod text;
|
||||||
|
|
||||||
pub use error::{PatchError, Result};
|
pub use error::{PatchError, Result};
|
||||||
|
pub use manifest::{
|
||||||
|
PatchIntegrity, PatchKind, PatchManifest, PatchManifestFile, PatchRollback,
|
||||||
|
PATCH_MANIFEST_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
/// Patch 引擎版本号
|
/// Patch 引擎版本号
|
||||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
//! Patch manifest, integrity and rollback primitives.
|
||||||
|
|
||||||
|
use crate::PatchError;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
/// Current patch manifest schema version.
|
||||||
|
pub const PATCH_MANIFEST_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Persisted manifest for a generated patch set.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PatchManifest {
|
||||||
|
/// Manifest schema version.
|
||||||
|
#[serde(default = "default_patch_manifest_version")]
|
||||||
|
pub version: u32,
|
||||||
|
/// Stable patch identifier.
|
||||||
|
pub patch_id: String,
|
||||||
|
/// Source resource version identifier.
|
||||||
|
pub source_version: String,
|
||||||
|
/// Target resource version identifier.
|
||||||
|
pub target_version: String,
|
||||||
|
/// Files covered by this patch set.
|
||||||
|
pub files: Vec<PatchManifestFile>,
|
||||||
|
/// Rollback metadata for the publication layer.
|
||||||
|
pub rollback: PatchRollback,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PatchManifest {
|
||||||
|
/// Builds a manifest-level integrity summary from recorded file metadata.
|
||||||
|
pub fn integrity_summary(&self) -> PatchIntegrity {
|
||||||
|
PatchIntegrity {
|
||||||
|
file_count: self.files.len(),
|
||||||
|
source_bytes: self.files.iter().map(|file| file.source_size).sum(),
|
||||||
|
target_bytes: self.files.iter().map(|file| file.target_size).sum(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One release-relative file entry in a patch manifest.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PatchManifestFile {
|
||||||
|
/// Release-relative path.
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// Patch algorithm used to produce the target bytes.
|
||||||
|
pub patch_kind: PatchKind,
|
||||||
|
/// Expected BLAKE3 hash of the source bytes.
|
||||||
|
pub source_blake3: String,
|
||||||
|
/// Expected BLAKE3 hash of the target bytes.
|
||||||
|
pub target_blake3: String,
|
||||||
|
/// Expected source byte length.
|
||||||
|
pub source_size: u64,
|
||||||
|
/// Expected target byte length.
|
||||||
|
pub target_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch algorithm family used by one manifest file.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PatchKind {
|
||||||
|
/// Deterministic binary hunk patch.
|
||||||
|
Binary,
|
||||||
|
/// RFC 6902 JSON Patch.
|
||||||
|
Json,
|
||||||
|
/// UTF-8 text patch.
|
||||||
|
Text,
|
||||||
|
/// UnityFS TextAsset replacement patch.
|
||||||
|
UnityFsTextAsset,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rollback metadata owned by higher-level publication code.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PatchRollback {
|
||||||
|
/// Previous `current` pointer target before publication.
|
||||||
|
pub previous_current_target: Option<PathBuf>,
|
||||||
|
/// Published target path that can be removed on rollback.
|
||||||
|
pub remove_target_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manifest-level integrity summary.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PatchIntegrity {
|
||||||
|
/// Number of manifest files verified or summarized.
|
||||||
|
pub file_count: usize,
|
||||||
|
/// Total source bytes.
|
||||||
|
pub source_bytes: u64,
|
||||||
|
/// Total target bytes.
|
||||||
|
pub target_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies all manifest files against source and target roots.
|
||||||
|
pub fn verify_patch_manifest_files(
|
||||||
|
source_root: &Path,
|
||||||
|
target_root: &Path,
|
||||||
|
manifest: &PatchManifest,
|
||||||
|
) -> crate::Result<PatchIntegrity> {
|
||||||
|
if manifest.version != PATCH_MANIFEST_VERSION {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"unsupported patch manifest version {}",
|
||||||
|
manifest.version
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut integrity = PatchIntegrity {
|
||||||
|
file_count: 0,
|
||||||
|
source_bytes: 0,
|
||||||
|
target_bytes: 0,
|
||||||
|
};
|
||||||
|
for file in &manifest.files {
|
||||||
|
let source_path = resolve_manifest_path(source_root, &file.path)?;
|
||||||
|
let target_path = resolve_manifest_path(target_root, &file.path)?;
|
||||||
|
let source = read_manifest_file(&source_path, "source")?;
|
||||||
|
let target = read_manifest_file(&target_path, "target")?;
|
||||||
|
verify_patch_file_bytes(&source, &target, file)?;
|
||||||
|
integrity.file_count += 1;
|
||||||
|
integrity.source_bytes += source.len() as u64;
|
||||||
|
integrity.target_bytes += target.len() as u64;
|
||||||
|
}
|
||||||
|
Ok(integrity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies one manifest file entry against source and target bytes.
|
||||||
|
pub fn verify_patch_file_bytes(
|
||||||
|
source: &[u8],
|
||||||
|
target: &[u8],
|
||||||
|
file: &PatchManifestFile,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
let source_hash = blake3_hex(source);
|
||||||
|
let target_hash = blake3_hex(target);
|
||||||
|
if source_hash != file.source_blake3 || source.len() as u64 != file.source_size {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"patch source integrity mismatch {}: expected hash={} bytes={}, actual hash={} bytes={}",
|
||||||
|
file.path.display(),
|
||||||
|
file.source_blake3,
|
||||||
|
file.source_size,
|
||||||
|
source_hash,
|
||||||
|
source.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if target_hash != file.target_blake3 || target.len() as u64 != file.target_size {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"patch target integrity mismatch {}: expected hash={} bytes={}, actual hash={} bytes={}",
|
||||||
|
file.path.display(),
|
||||||
|
file.target_blake3,
|
||||||
|
file.target_size,
|
||||||
|
target_hash,
|
||||||
|
target.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf> {
|
||||||
|
if relative.is_absolute() {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"patch manifest path must be relative: {}",
|
||||||
|
relative.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for component in relative.components() {
|
||||||
|
match component {
|
||||||
|
Component::Normal(_) | Component::CurDir => {}
|
||||||
|
_ => {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"patch manifest path escapes release root: {}",
|
||||||
|
relative.display()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(root.join(relative))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_manifest_file(path: &Path, label: &str) -> crate::Result<Vec<u8>> {
|
||||||
|
fs::read(path).map_err(|error| {
|
||||||
|
PatchError::ApplyFailed(format!(
|
||||||
|
"failed to read patch {label} file {}: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blake3_hex(bytes: &[u8]) -> String {
|
||||||
|
blake3::hash(bytes).to_hex().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_patch_manifest_version() -> u32 {
|
||||||
|
PATCH_MANIFEST_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_patch_manifest_files_accepts_matching_roots() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let source_root = temp.path().join("source");
|
||||||
|
let target_root = temp.path().join("target");
|
||||||
|
fs::create_dir_all(source_root.join("TableBundles")).unwrap();
|
||||||
|
fs::create_dir_all(target_root.join("TableBundles")).unwrap();
|
||||||
|
let source = b"before";
|
||||||
|
let target = b"after";
|
||||||
|
fs::write(source_root.join("TableBundles/file.bytes"), source).unwrap();
|
||||||
|
fs::write(target_root.join("TableBundles/file.bytes"), target).unwrap();
|
||||||
|
|
||||||
|
let manifest = manifest_for("TableBundles/file.bytes", source, target);
|
||||||
|
let integrity = verify_patch_manifest_files(&source_root, &target_root, &manifest).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
integrity,
|
||||||
|
PatchIntegrity {
|
||||||
|
file_count: 1,
|
||||||
|
source_bytes: source.len() as u64,
|
||||||
|
target_bytes: target.len() as u64,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(manifest.integrity_summary(), integrity);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_patch_manifest_files_rejects_path_escape() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = manifest_for("../escape", b"source", b"target");
|
||||||
|
|
||||||
|
let error = verify_patch_manifest_files(temp.path(), temp.path(), &manifest).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, PatchError::ApplyFailed(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_patch_file_bytes_rejects_hash_mismatch() {
|
||||||
|
let mut manifest = manifest_for("file.bin", b"source", b"target");
|
||||||
|
manifest.files[0].target_blake3 = blake3_hex(b"other");
|
||||||
|
|
||||||
|
let error = verify_patch_file_bytes(b"source", b"target", &manifest.files[0]).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, PatchError::ApplyFailed(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest {
|
||||||
|
PatchManifest {
|
||||||
|
version: PATCH_MANIFEST_VERSION,
|
||||||
|
patch_id: "patch-id".to_string(),
|
||||||
|
source_version: "source-version".to_string(),
|
||||||
|
target_version: "target-version".to_string(),
|
||||||
|
files: vec![PatchManifestFile {
|
||||||
|
path: PathBuf::from(path),
|
||||||
|
patch_kind: PatchKind::Binary,
|
||||||
|
source_blake3: blake3_hex(source),
|
||||||
|
target_blake3: blake3_hex(target),
|
||||||
|
source_size: source.len() as u64,
|
||||||
|
target_size: target.len() as u64,
|
||||||
|
}],
|
||||||
|
rollback: PatchRollback {
|
||||||
|
previous_current_target: None,
|
||||||
|
remove_target_path: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
//! 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(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user