From f4880a71bde0ace7892ebada6d2ffd5d47d0016b Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Fri, 31 Jul 2026 00:21:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(patch):=20=E8=A1=A5=E9=BD=90=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E8=A1=A5=E4=B8=81=E5=BC=95=E6=93=8E=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 Binary、JSON、Text patch 与通用 manifest 校验基础。 验证:cargo test -p bat-patch --locked;cargo clippy -p bat-patch --all-targets --locked -- -D warnings。 --- crates/bat-patch/src/binary.rs | 168 +++++++++++++- crates/bat-patch/src/json.rs | 364 ++++++++++++++++++++++++++++++- crates/bat-patch/src/lib.rs | 6 + crates/bat-patch/src/manifest.rs | 261 ++++++++++++++++++++++ crates/bat-patch/src/text.rs | 305 ++++++++++++++++++++++++++ 5 files changed, 1084 insertions(+), 20 deletions(-) create mode 100644 crates/bat-patch/src/manifest.rs create mode 100644 crates/bat-patch/src/text.rs diff --git a/crates/bat-patch/src/binary.rs b/crates/bat-patch/src/binary.rs index 53c66a8..5cc2968 100644 --- a/crates/bat-patch/src/binary.rs +++ b/crates/bat-patch/src/binary.rs @@ -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, +} + +/// 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, + }, +} + +/// Creates a deterministic hunk patch. /// -/// 返回 [`crate::PatchError::ApplyFailed`] 而非空结果,避免调用方把未实现的 -/// 占位当成一次成功的补丁应用。 -pub fn apply_patch(_old: &[u8], _patch: &[u8]) -> crate::Result> { - 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> { + 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> { + 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(_))); } } diff --git a/crates/bat-patch/src/json.rs b/crates/bat-patch/src/json.rs index 8088a8d..d4ec871 100644 --- a/crates/bat-patch/src/json.rs +++ b/crates/bat-patch/src/json.rs @@ -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 { + let mut document: Value = serde_json::from_str(doc) + .map_err(|error| PatchError::ApplyFailed(format!("invalid JSON document: {error}")))?; + let operations: Vec = 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 { - 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 { + 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 { + if token.is_empty() || token == "-" { + return Err(failed(format!("invalid JSON patch array index {token}"))); + } + let index = token + .parse::() + .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> { + 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 { + 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(_))); } } diff --git a/crates/bat-patch/src/lib.rs b/crates/bat-patch/src/lib.rs index 6b43cc4..c956b52 100644 --- a/crates/bat-patch/src/lib.rs +++ b/crates/bat-patch/src/lib.rs @@ -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"); diff --git a/crates/bat-patch/src/manifest.rs b/crates/bat-patch/src/manifest.rs new file mode 100644 index 0000000..fb6646d --- /dev/null +++ b/crates/bat-patch/src/manifest.rs @@ -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, + /// 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, + /// Published target path that can be removed on rollback. + pub remove_target_path: Option, +} + +/// 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 { + 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 { + 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> { + 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, + }, + } + } +} diff --git a/crates/bat-patch/src/text.rs b/crates/bat-patch/src/text.rs new file mode 100644 index 0000000..795761a --- /dev/null +++ b/crates/bat-patch/src/text.rs @@ -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, +} + +/// 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, + /// 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, +) -> crate::Result { + 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 { + 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 { + 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> { + 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 { + 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(_))); + } +}