feat(patch):统一清单驱动汉化发布
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-12 01:35:29 +08:00
parent 69b6e36bf0
commit 8a77502272
26 changed files with 2250 additions and 158 deletions
+56 -2
View File
@@ -149,8 +149,18 @@ pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<V
patch.path_id,
None,
)?;
let asset = verified
.text_assets
let verified_serialized = verified
.serialized_files
.iter()
.find(|file| file.source_path.as_deref() == Some(patch.serialized_file_path.as_str()))
.ok_or_else(|| {
AssetBundleError::Parse(format!(
"patched serialized file {} was not found after rebuild",
patch.serialized_file_path
))
})?;
let asset = verified_serialized
.text_assets()
.iter()
.find(|asset| asset.path_id == patch.path_id)
.ok_or_else(|| {
@@ -2119,6 +2129,50 @@ mod tests {
);
}
#[test]
fn post_rebuild_text_asset_verification_scopes_duplicate_path_id_to_serialized_file() {
let first = synthetic_serialized_text_asset(b"first");
let second = synthetic_serialized_text_asset(b"second");
let source = synthetic_bundle_with_compressed_blocks(
&[("CAB-first", &first), ("CAB-second", &second)],
0,
0,
);
let patched = patch_unityfs_text_asset(
&source,
&TextAssetPatch {
serialized_file_path: "CAB-second".to_string(),
path_id: 1,
expected_name: Some("Scenario".to_string()),
replacement: b"localized-second".to_vec(),
},
)
.unwrap();
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
assert_eq!(
reparsed
.serialized_files
.iter()
.find(|file| file.source_path.as_deref() == Some("CAB-first"))
.unwrap()
.text_assets()[0]
.bytes,
b"first"
);
assert_eq!(
reparsed
.serialized_files
.iter()
.find(|file| file.source_path.as_deref() == Some("CAB-second"))
.unwrap()
.text_assets()[0]
.bytes,
b"localized-second"
);
}
#[test]
fn rebuild_preserves_block_info_at_end_and_lzma_compression() {
let serialized = synthetic_serialized_text_asset(b"old");
+3 -2
View File
@@ -19,8 +19,9 @@ pub mod text;
pub use error::{PatchError, Result};
pub use manifest::{
PatchIntegrity, PatchKind, PatchManifest, PatchManifestFile, PatchRollback,
PATCH_MANIFEST_VERSION,
build_patch_manifest, validate_patch_manifest, verify_patch_file_bytes, PatchIntegrity,
PatchKind, PatchManifest, PatchManifestBuildFile, PatchManifestFile, PatchManifestOperation,
PatchManifestOperationPayload, PatchManifestProvenance, PatchRollback, PATCH_MANIFEST_VERSION,
};
/// Patch 引擎版本号
+663 -9
View File
@@ -1,7 +1,8 @@
//! Patch manifest, integrity and rollback primitives.
use crate::PatchError;
use crate::{binary::BinaryPatch, json::JsonPatchOperation, text::TextPatch, PatchError};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use std::path::{Component, Path, PathBuf};
@@ -52,10 +53,30 @@ pub struct PatchManifestFile {
pub source_size: u64,
/// Expected target byte length.
pub target_size: u64,
/// Ordered operations that produce the target bytes.
#[serde(default)]
pub operations: Vec<PatchManifestOperation>,
}
/// Input specification for building one manifest file from verified source and
/// target release roots.
///
/// Operation payloads and provenance are deliberately independent from
/// filesystem metadata. This keeps the manifest builder usable for UnityFS operations,
/// whose bytes are produced by `bat-assetbundle`, while still requiring the
/// resulting source and target files to exist and match the recorded manifest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchManifestBuildFile {
/// Release-relative path.
pub path: PathBuf,
/// Patch kind declared for this file.
pub patch_kind: PatchKind,
/// Ordered operations, including archive and provenance metadata.
pub operations: Vec<PatchManifestOperation>,
}
/// Patch algorithm family used by one manifest file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PatchKind {
/// Deterministic binary hunk patch.
@@ -65,7 +86,185 @@ pub enum PatchKind {
/// UTF-8 text patch.
Text,
/// UnityFS TextAsset replacement patch.
#[serde(rename = "unityfs_text_asset")]
UnityFsTextAsset,
/// UnityFS TypeTree string-field replacement patch.
#[serde(rename = "unityfs_string_field")]
UnityFsStringField,
/// UnityFS semantic TypeTree field replacement patch.
#[serde(rename = "unityfs_field")]
UnityFsField,
/// A file containing more than one supported operation kind.
Mixed,
}
/// One ordered, auditable operation in a manifest file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchManifestOperation {
/// Stable zero-based order within the file.
pub sequence: u32,
/// Optional BLAKE3 hash of the bytes immediately before this operation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_blake3: Option<String>,
/// Optional size of the bytes immediately before this operation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_size: Option<u64>,
/// Optional archive entry for a UnityFS bundle nested in a ZIP.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive_entry: Option<String>,
/// Algorithm payload and UnityFS target location.
#[serde(flatten)]
pub payload: PatchManifestOperationPayload,
/// Translation and review provenance, when this operation came from a
/// localized workflow.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<PatchManifestProvenance>,
}
/// Supported operation payloads in the generic manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PatchManifestOperationPayload {
/// Deterministic binary hunk patch.
Binary {
/// Complete binary patch document.
patch: BinaryPatch,
},
/// RFC 6902 JSON Patch document.
Json {
/// JSON Patch array. It is retained as JSON to preserve the wire
/// contract while the algorithm crate validates each operation.
patch: Value,
},
/// UTF-8 text patch.
Text {
/// Complete text patch document.
patch: TextPatch,
},
/// UnityFS TextAsset replacement.
#[serde(rename = "unityfs_text_asset")]
UnityFsTextAsset {
/// Serialized file path in the UnityFS directory table.
serialized_file_path: String,
/// Unity object path ID.
path_id: i64,
/// Optional expected TextAsset name.
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_name: Option<String>,
/// Replacement bytes.
replacement: Vec<u8>,
},
/// UnityFS TypeTree string field replacement.
#[serde(rename = "unityfs_string_field")]
UnityFsStringField {
/// Serialized file path in the UnityFS directory table.
serialized_file_path: String,
/// Unity object path ID.
path_id: i64,
/// TypeTree field path.
field_path: String,
/// Optional expected source string.
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_value: Option<String>,
/// Replacement string.
replacement: String,
},
/// UnityFS semantic TypeTree field replacement.
#[serde(rename = "unityfs_field")]
UnityFsField {
/// Serialized file path in the UnityFS directory table.
serialized_file_path: String,
/// Unity object path ID.
path_id: i64,
/// TypeTree field path.
field_path: String,
/// Optional expected semantic source value.
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_value: Option<Value>,
/// Replacement semantic value using the Unity serialized value schema.
replacement: Value,
},
}
impl PatchManifestOperationPayload {
/// Returns the file-level patch kind represented by this payload.
pub fn patch_kind(&self) -> PatchKind {
match self {
Self::Binary { .. } => PatchKind::Binary,
Self::Json { .. } => PatchKind::Json,
Self::Text { .. } => PatchKind::Text,
Self::UnityFsTextAsset { .. } => PatchKind::UnityFsTextAsset,
Self::UnityFsStringField { .. } => PatchKind::UnityFsStringField,
Self::UnityFsField { .. } => PatchKind::UnityFsField,
}
}
fn validate(&self) -> crate::Result<()> {
match self {
Self::Binary { patch } if patch.version != crate::binary::BINARY_PATCH_VERSION => {
return Err(PatchError::ApplyFailed(format!(
"unsupported binary patch version {}",
patch.version
)))
}
Self::Text { patch } if patch.version != crate::text::TEXT_PATCH_VERSION => {
return Err(PatchError::ApplyFailed(format!(
"unsupported text patch version {}",
patch.version
)))
}
Self::Json { patch } => {
serde_json::from_value::<Vec<JsonPatchOperation>>(patch.clone()).map_err(
|error| {
PatchError::ApplyFailed(format!(
"invalid JSON patch operation list: {error}"
))
},
)?;
}
_ => {}
}
Ok(())
}
}
/// Provenance retained for a localized operation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatchManifestProvenance {
/// Stable TextUnit identifier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_unit_id: Option<String>,
/// BLAKE3 of the validated source text.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_text_blake3: Option<String>,
/// Translation provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_provider: Option<String>,
/// Provider run identifier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_run_id: Option<String>,
/// Translation source kind.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_source_kind: Option<String>,
/// Trusted Translation Memory record identifier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_memory_record_id: Option<String>,
/// Review status.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_status: Option<String>,
/// Deterministic Glossary QA report.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<Value>,
/// Explicit Glossary QA override.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<Value>,
}
impl PatchManifestOperation {
/// Returns the operation kind.
pub fn patch_kind(&self) -> PatchKind {
self.payload.patch_kind()
}
}
/// Rollback metadata owned by higher-level publication code.
@@ -94,12 +293,7 @@ pub fn verify_patch_manifest_files(
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
)));
}
validate_patch_manifest(manifest)?;
let mut integrity = PatchIntegrity {
file_count: 0,
@@ -119,6 +313,275 @@ pub fn verify_patch_manifest_files(
Ok(integrity)
}
/// Validates manifest schema, paths, operation order and kind compatibility.
pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
if manifest.version != PATCH_MANIFEST_VERSION {
return Err(PatchError::ApplyFailed(format!(
"unsupported patch manifest version {}",
manifest.version
)));
}
for (label, value) in [
("patch_id", manifest.patch_id.as_str()),
("source_version", manifest.source_version.as_str()),
("target_version", manifest.target_version.as_str()),
] {
if value.is_empty()
|| value.contains('\0')
|| value.contains('/')
|| value.contains('\\')
|| value == "."
|| value == ".."
{
return Err(PatchError::ApplyFailed(format!(
"invalid patch manifest {label}: {value}"
)));
}
}
let mut paths = std::collections::BTreeSet::new();
for file in &manifest.files {
resolve_manifest_path(Path::new("."), &file.path)?;
if !paths.insert(file.path.clone()) {
return Err(PatchError::ApplyFailed(format!(
"patch manifest contains duplicate file path: {}",
file.path.display()
)));
}
let mut targets = std::collections::BTreeSet::new();
for (expected_sequence, operation) in file.operations.iter().enumerate() {
operation.payload.validate()?;
if operation.sequence != expected_sequence as u32 {
return Err(PatchError::ApplyFailed(format!(
"patch manifest operation order is not contiguous for {}: expected {}, got {}",
file.path.display(),
expected_sequence,
operation.sequence
)));
}
if let Some(archive_entry) = operation.archive_entry.as_deref() {
validate_archive_entry(archive_entry)?;
}
if operation.source_blake3.is_some() != operation.source_size.is_some() {
return Err(PatchError::ApplyFailed(format!(
"patch manifest operation source precondition must include hash and size: {} operation {}",
file.path.display(),
operation.sequence
)));
}
if file.operations.len() > 1
&& (operation.source_blake3.is_none() || operation.source_size.is_none())
{
return Err(PatchError::ApplyFailed(format!(
"multiple operations require source preconditions: {} operation {}",
file.path.display(),
operation.sequence
)));
}
if operation.archive_entry.is_some()
&& !matches!(
operation.payload,
PatchManifestOperationPayload::UnityFsTextAsset { .. }
| PatchManifestOperationPayload::UnityFsStringField { .. }
| PatchManifestOperationPayload::UnityFsField { .. }
)
{
return Err(PatchError::ApplyFailed(format!(
"archive_entry is only supported for UnityFS operations: {} operation {}",
file.path.display(),
operation.sequence
)));
}
if let Some(target) = unity_operation_target(operation) {
if !targets.insert(target) {
return Err(PatchError::ApplyFailed(format!(
"patch manifest repeats an incompatible UnityFS target: {} operation {}",
file.path.display(),
operation.sequence
)));
}
}
if file.patch_kind != PatchKind::Mixed && file.patch_kind != operation.patch_kind() {
return Err(PatchError::ApplyFailed(format!(
"patch manifest kind mismatch for {} operation {}",
file.path.display(),
operation.sequence
)));
}
}
}
Ok(())
}
fn unity_operation_target(operation: &PatchManifestOperation) -> Option<(Option<&str>, &str, i64)> {
let (serialized_file_path, path_id) = match &operation.payload {
PatchManifestOperationPayload::UnityFsTextAsset {
serialized_file_path,
path_id,
..
} => (serialized_file_path, *path_id),
PatchManifestOperationPayload::UnityFsStringField {
serialized_file_path,
path_id,
..
}
| PatchManifestOperationPayload::UnityFsField {
serialized_file_path,
path_id,
..
} => (serialized_file_path, *path_id),
_ => return None,
};
Some((
operation.archive_entry.as_deref(),
serialized_file_path.as_str(),
path_id,
))
}
/// Builds a manifest from release-root bytes and ordered operation payloads.
///
/// This function does not infer or apply operations. Callers construct target
/// bytes with the owning algorithm/adapter first, then this builder binds the
/// actual source and target hash/size to the auditable manifest. Operation
/// sequence numbers are assigned from the supplied vector order.
pub fn build_patch_manifest(
source_root: &Path,
target_root: &Path,
patch_id: impl Into<String>,
source_version: impl Into<String>,
target_version: impl Into<String>,
files: Vec<PatchManifestBuildFile>,
rollback: PatchRollback,
) -> crate::Result<PatchManifest> {
let mut manifest_files = Vec::with_capacity(files.len());
for file in 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")?;
let has_unity_operation = file.operations.iter().any(|operation| {
matches!(
&operation.payload,
PatchManifestOperationPayload::UnityFsTextAsset { .. }
| PatchManifestOperationPayload::UnityFsStringField { .. }
| PatchManifestOperationPayload::UnityFsField { .. }
)
});
let has_archive_operation = file
.operations
.iter()
.any(|operation| operation.archive_entry.is_some());
if has_archive_operation && !has_unity_operation {
return Err(PatchError::ApplyFailed(format!(
"manifest builder archive operations must be UnityFS operations: {}",
file.path.display()
)));
}
if file.operations.len() > 1 && has_unity_operation {
return Err(PatchError::ApplyFailed(format!(
"manifest builder requires one UnityFS operation per file; use adapter-supplied manifest operations for multiple UnityFS targets: {}",
file.path.display()
)));
}
let operation_count = file.operations.len();
let mut current = source.clone();
let operations = file
.operations
.into_iter()
.enumerate()
.map(|(sequence, mut operation)| {
operation.payload.validate()?;
let source_blake3 = (operation_count > 1).then(|| blake3_hex(&current));
let source_size = (operation_count > 1).then_some(current.len() as u64);
if operation.source_blake3.is_none() && operation_count > 1 {
if has_archive_operation || has_unity_operation {
return Err(PatchError::ApplyFailed(format!(
"manifest builder requires source preconditions for multiple non-direct operations: {}",
file.path.display()
)));
}
operation.source_blake3 = source_blake3;
operation.source_size = source_size;
}
if let (Some(expected_hash), Some(expected_size)) =
(operation.source_blake3.as_deref(), operation.source_size)
{
if expected_hash != blake3_hex(&current)
|| expected_size != current.len() as u64
{
return Err(PatchError::ApplyFailed(format!(
"manifest builder operation source precondition mismatch: {} operation {}",
file.path.display(),
sequence
)));
}
}
if !has_unity_operation && !has_archive_operation {
current = apply_direct_payload(&current, &operation.payload)?;
}
operation.sequence = sequence as u32;
Ok(operation)
})
.collect::<crate::Result<Vec<_>>>()?;
if !has_unity_operation && !has_archive_operation && current != target {
return Err(PatchError::ApplyFailed(format!(
"manifest builder operations do not produce target bytes: {}",
file.path.display()
)));
}
manifest_files.push(PatchManifestFile {
path: file.path,
patch_kind: file.patch_kind,
source_blake3: blake3_hex(&source),
target_blake3: blake3_hex(&target),
source_size: source.len() as u64,
target_size: target.len() as u64,
operations,
});
}
let manifest = PatchManifest {
version: PATCH_MANIFEST_VERSION,
patch_id: patch_id.into(),
source_version: source_version.into(),
target_version: target_version.into(),
files: manifest_files,
rollback,
};
validate_patch_manifest(&manifest)?;
Ok(manifest)
}
fn apply_direct_payload(
source: &[u8],
payload: &PatchManifestOperationPayload,
) -> crate::Result<Vec<u8>> {
match payload {
PatchManifestOperationPayload::Binary { patch } => {
crate::binary::apply_binary_patch(source, patch)
}
PatchManifestOperationPayload::Json { patch } => {
let source = std::str::from_utf8(source).map_err(|error| {
PatchError::ApplyFailed(format!("JSON patch source is not UTF-8: {error}"))
})?;
let patch = serde_json::to_string(patch).map_err(|error| {
PatchError::ApplyFailed(format!("failed to serialize JSON patch: {error}"))
})?;
crate::json::apply_json_patch(source, &patch).map(|value| value.into_bytes())
}
PatchManifestOperationPayload::Text { patch } => {
let source = std::str::from_utf8(source).map_err(|error| {
PatchError::ApplyFailed(format!("text patch source is not UTF-8: {error}"))
})?;
crate::text::apply_text_patch(source, patch).map(|value| value.into_bytes())
}
PatchManifestOperationPayload::UnityFsTextAsset { .. }
| PatchManifestOperationPayload::UnityFsStringField { .. }
| PatchManifestOperationPayload::UnityFsField { .. } => Err(PatchError::ApplyFailed(
"manifest builder cannot apply UnityFS payload without bat-assetbundle".to_string(),
)),
}
}
/// Verifies one manifest file entry against source and target bytes.
pub fn verify_patch_file_bytes(
source: &[u8],
@@ -151,7 +614,14 @@ pub fn verify_patch_file_bytes(
}
fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf> {
if relative.is_absolute() {
if relative.is_absolute()
|| relative.as_os_str().is_empty()
|| relative.to_string_lossy().contains('\0')
|| relative.to_string_lossy().contains('\\')
|| relative == Path::new(".")
|| relative.components().count() == 0
|| relative.to_string_lossy().as_bytes().get(1) == Some(&b':')
{
return Err(PatchError::ApplyFailed(format!(
"patch manifest path must be relative: {}",
relative.display()
@@ -171,6 +641,26 @@ fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf>
Ok(root.join(relative))
}
fn validate_archive_entry(entry: &str) -> crate::Result<()> {
if entry.is_empty()
|| entry.contains('\0')
|| entry.contains('\\')
|| entry.as_bytes().get(1) == Some(&b':')
{
return Err(PatchError::ApplyFailed(format!(
"patch manifest archive entry is unsafe: {entry}"
)));
}
for component in Path::new(entry).components() {
if !matches!(component, Component::Normal(_) | Component::CurDir) {
return Err(PatchError::ApplyFailed(format!(
"patch manifest archive entry escapes archive root: {entry}"
)));
}
}
Ok(())
}
fn read_manifest_file(path: &Path, label: &str) -> crate::Result<Vec<u8>> {
fs::read(path).map_err(|error| {
PatchError::ApplyFailed(format!(
@@ -238,6 +728,169 @@ mod tests {
assert!(matches!(error, PatchError::ApplyFailed(_)));
}
#[test]
fn build_patch_manifest_binds_release_metadata_and_operation_order() {
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).unwrap();
fs::create_dir_all(&target_root).unwrap();
let source = b"before";
let target = b"after";
fs::write(source_root.join("file.bin"), source).unwrap();
fs::write(target_root.join("file.bin"), target).unwrap();
let manifest = build_patch_manifest(
&source_root,
&target_root,
"localized-v1",
"official-v1",
"localized-v1",
vec![PatchManifestBuildFile {
path: PathBuf::from("file.bin"),
patch_kind: PatchKind::Binary,
operations: vec![PatchManifestOperation {
sequence: 99,
source_blake3: None,
source_size: None,
archive_entry: None,
payload: PatchManifestOperationPayload::Binary {
patch: crate::binary::diff(source, target),
},
provenance: None,
}],
}],
PatchRollback {
previous_current_target: None,
remove_target_path: None,
},
)
.unwrap();
assert_eq!(manifest.source_version, "official-v1");
assert_eq!(manifest.files[0].source_blake3, blake3_hex(source));
assert_eq!(manifest.files[0].target_blake3, blake3_hex(target));
assert_eq!(manifest.files[0].operations[0].sequence, 0);
}
#[test]
fn build_patch_manifest_records_each_direct_operation_source_precondition() {
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).unwrap();
fs::create_dir_all(&target_root).unwrap();
let source = b"before";
let intermediate = b"middle";
let target = b"after";
fs::write(source_root.join("file.bin"), source).unwrap();
fs::write(target_root.join("file.bin"), target).unwrap();
let first = crate::binary::diff(source, intermediate);
let second = crate::binary::diff(intermediate, target);
let manifest = build_patch_manifest(
&source_root,
&target_root,
"localized-v1",
"official-v1",
"localized-v1",
vec![PatchManifestBuildFile {
path: PathBuf::from("file.bin"),
patch_kind: PatchKind::Binary,
operations: vec![
PatchManifestOperation {
sequence: 20,
source_blake3: None,
source_size: None,
archive_entry: None,
payload: PatchManifestOperationPayload::Binary { patch: first },
provenance: None,
},
PatchManifestOperation {
sequence: 21,
source_blake3: None,
source_size: None,
archive_entry: None,
payload: PatchManifestOperationPayload::Binary { patch: second },
provenance: None,
},
],
}],
PatchRollback {
previous_current_target: None,
remove_target_path: None,
},
)
.unwrap();
assert_eq!(
manifest.files[0].operations[0].source_blake3,
Some(blake3_hex(source))
);
assert_eq!(
manifest.files[0].operations[1].source_blake3,
Some(blake3_hex(intermediate))
);
}
#[test]
fn validate_patch_manifest_rejects_invalid_json_operation_payload() {
let mut manifest = manifest_for("file.json", b"{}", b"{\"value\":1}");
manifest.files[0].patch_kind = PatchKind::Json;
manifest.files[0].operations = vec![PatchManifestOperation {
sequence: 0,
source_blake3: None,
source_size: None,
archive_entry: None,
payload: PatchManifestOperationPayload::Json {
patch: serde_json::json!({"op": "replace", "path": "/value", "value": 1}),
},
provenance: None,
}];
assert!(validate_patch_manifest(&manifest).is_err());
}
#[test]
fn validate_patch_manifest_rejects_repeated_unity_object_target() {
let source = b"source";
let target = b"target";
let mut manifest = manifest_for("bundle", source, target);
manifest.files[0].patch_kind = PatchKind::Mixed;
manifest.files[0].operations = vec![
PatchManifestOperation {
sequence: 0,
source_blake3: Some(blake3_hex(source)),
source_size: Some(source.len() as u64),
archive_entry: None,
payload: PatchManifestOperationPayload::UnityFsStringField {
serialized_file_path: "CAB-one".to_string(),
path_id: 7,
field_path: "first".to_string(),
expected_value: None,
replacement: "one".to_string(),
},
provenance: None,
},
PatchManifestOperation {
sequence: 1,
source_blake3: Some(blake3_hex(target)),
source_size: Some(target.len() as u64),
archive_entry: None,
payload: PatchManifestOperationPayload::UnityFsField {
serialized_file_path: "CAB-one".to_string(),
path_id: 7,
field_path: "second".to_string(),
expected_value: None,
replacement: serde_json::json!({"kind": "string", "value": "two"}),
},
provenance: None,
},
];
assert!(validate_patch_manifest(&manifest).is_err());
}
fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest {
PatchManifest {
version: PATCH_MANIFEST_VERSION,
@@ -251,6 +904,7 @@ mod tests {
target_blake3: blake3_hex(target),
source_size: source.len() as u64,
target_size: target.len() as u64,
operations: Vec::new(),
}],
rollback: PatchRollback {
previous_current_target: None,