mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 08:14:55 +08:00
实现 Binary、JSON、Text patch 与通用 manifest 校验基础。 验证:cargo test -p bat-patch --locked;cargo clippy -p bat-patch --all-targets --locked -- -D warnings。
262 lines
8.7 KiB
Rust
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,
|
|
},
|
|
}
|
|
}
|
|
}
|