Files
BlueArchiveToolkit/crates/bat-patch/src/manifest.rs
T
nyaKazuha f4880a71bd feat(patch): 补齐通用补丁引擎基础
实现 Binary、JSON、Text patch 与通用 manifest 校验基础。

验证:cargo test -p bat-patch --locked;cargo clippy -p bat-patch --all-targets --locked -- -D warnings。
2026-07-31 00:21:50 +08:00

262 lines
8.7 KiB
Rust

//! 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,
},
}
}
}