mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 14:14:53 +08:00
feat(patch): 补齐通用补丁引擎基础
实现 Binary、JSON、Text patch 与通用 manifest 校验基础。 验证:cargo test -p bat-patch --locked;cargo clippy -p bat-patch --all-targets --locked -- -D warnings。
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user