//! 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, /// 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, /// Published target path that can be removed on rollback. pub remove_target_path: Option, } /// 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 { 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 { 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> { 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, }, } } }