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:
2026-07-31 00:21:50 +08:00
parent 3e9bb20d79
commit f4880a71bd
5 changed files with 1084 additions and 20 deletions
+157 -9
View File
@@ -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`] 而非空结果,避免调用方把未实现的
/// 占位当成一次成功的补丁应用。
pub fn apply_patch(_old: &[u8], _patch: &[u8]) -> crate::Result<Vec<u8>> {
Err(crate::PatchError::ApplyFailed(
"binary patch 尚未实现".to_string(),
/// The first implementation optimizes for correctness and stable output. It
/// emits copy hunks for equal runs and insert hunks for changed runs; more
/// compact suffix/prefix matching can be added later without changing the
/// manifest/integrity contract.
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)]
@@ -15,8 +147,24 @@ mod tests {
use super::*;
#[test]
fn apply_patch_reports_not_implemented() {
let error = apply_patch(b"old", b"patch").unwrap_err();
fn binary_patch_round_trips_changed_bytes() {
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(_)));
}
}
+354 -10
View File
@@ -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`] 而非空字符串,避免调用方把未实现的
/// 占位当成一次成功的补丁应用。
pub fn apply_json_patch(_doc: &str, _patch: &str) -> crate::Result<String> {
Err(crate::PatchError::ApplyFailed(
"json patch 尚未实现".to_string(),
))
/// 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_value(
document: &mut Value,
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)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn apply_json_patch_reports_not_implemented() {
let error = apply_json_patch("{}", "[]").unwrap_err();
fn apply_json_patch_handles_all_core_operations() {
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(_)));
}
}
+6
View File
@@ -14,8 +14,14 @@
pub mod binary;
pub mod error;
pub mod json;
pub mod manifest;
pub mod text;
pub use error::{PatchError, Result};
pub use manifest::{
PatchIntegrity, PatchKind, PatchManifest, PatchManifestFile, PatchRollback,
PATCH_MANIFEST_VERSION,
};
/// Patch 引擎版本号
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+261
View File
@@ -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,
},
}
}
}
+305
View File
@@ -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(_)));
}
}