mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
feat(patch):统一清单驱动汉化发布
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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 引擎版本号
|
||||
|
||||
@@ -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(¤t));
|
||||
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(¤t)
|
||||
|| 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(¤t, &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,
|
||||
|
||||
Reference in New Issue
Block a user