mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
feat(release):完成双 release 运维闭环
This commit is contained in:
@@ -347,7 +347,11 @@ pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
|
||||
file.path.display()
|
||||
)));
|
||||
}
|
||||
let mut targets = std::collections::BTreeSet::new();
|
||||
let unity_operations = file
|
||||
.operations
|
||||
.iter()
|
||||
.filter_map(unity_operation_target)
|
||||
.collect::<Vec<_>>();
|
||||
for (expected_sequence, operation) in file.operations.iter().enumerate() {
|
||||
operation.payload.validate()?;
|
||||
if operation.sequence != expected_sequence as u32 {
|
||||
@@ -391,15 +395,6 @@ pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
|
||||
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 {}",
|
||||
@@ -408,34 +403,153 @@ pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
|
||||
)));
|
||||
}
|
||||
}
|
||||
for (index, left) in unity_operations.iter().enumerate() {
|
||||
for right in unity_operations.iter().skip(index + 1) {
|
||||
if unity_operation_targets_conflict(left, right) {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"patch manifest contains overlapping UnityFS targets in {}: {} and {}",
|
||||
file.path.display(),
|
||||
left.describe(),
|
||||
right.describe()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unity_operation_target(operation: &PatchManifestOperation) -> Option<(Option<&str>, &str, i64)> {
|
||||
let (serialized_file_path, path_id) = match &operation.payload {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum UnityOperationTarget<'a> {
|
||||
WholeObject {
|
||||
archive_entry: Option<&'a str>,
|
||||
serialized_file_path: &'a str,
|
||||
path_id: i64,
|
||||
},
|
||||
Field {
|
||||
archive_entry: Option<&'a str>,
|
||||
serialized_file_path: &'a str,
|
||||
path_id: i64,
|
||||
field_path: &'a str,
|
||||
},
|
||||
}
|
||||
|
||||
impl UnityOperationTarget<'_> {
|
||||
fn archive_entry(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::WholeObject { archive_entry, .. } | Self::Field { archive_entry, .. } => {
|
||||
*archive_entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serialized_file_path(&self) -> &str {
|
||||
match self {
|
||||
Self::WholeObject {
|
||||
serialized_file_path,
|
||||
..
|
||||
}
|
||||
| Self::Field {
|
||||
serialized_file_path,
|
||||
..
|
||||
} => serialized_file_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_id(&self) -> i64 {
|
||||
match self {
|
||||
Self::WholeObject { path_id, .. } | Self::Field { path_id, .. } => *path_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn describe(self) -> String {
|
||||
match self {
|
||||
Self::WholeObject {
|
||||
archive_entry,
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
} => format!(
|
||||
"archive={archive_entry:?}, serialized_file={serialized_file_path}, path_id={path_id}, object"
|
||||
),
|
||||
Self::Field {
|
||||
archive_entry,
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
field_path,
|
||||
} => format!(
|
||||
"archive={archive_entry:?}, serialized_file={serialized_file_path}, path_id={path_id}, field={field_path}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unity_operation_target(operation: &PatchManifestOperation) -> Option<UnityOperationTarget<'_>> {
|
||||
let archive_entry = operation.archive_entry.as_deref();
|
||||
match &operation.payload {
|
||||
PatchManifestOperationPayload::UnityFsTextAsset {
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
..
|
||||
} => (serialized_file_path, *path_id),
|
||||
} => Some(UnityOperationTarget::WholeObject {
|
||||
archive_entry,
|
||||
serialized_file_path,
|
||||
path_id: *path_id,
|
||||
}),
|
||||
PatchManifestOperationPayload::UnityFsStringField {
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
field_path,
|
||||
..
|
||||
}
|
||||
| PatchManifestOperationPayload::UnityFsField {
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
field_path,
|
||||
..
|
||||
} => (serialized_file_path, *path_id),
|
||||
_ => return None,
|
||||
};
|
||||
Some((
|
||||
operation.archive_entry.as_deref(),
|
||||
serialized_file_path.as_str(),
|
||||
path_id,
|
||||
))
|
||||
} => Some(UnityOperationTarget::Field {
|
||||
archive_entry,
|
||||
serialized_file_path,
|
||||
path_id: *path_id,
|
||||
field_path,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unity_operation_targets_conflict(
|
||||
left: &UnityOperationTarget<'_>,
|
||||
right: &UnityOperationTarget<'_>,
|
||||
) -> bool {
|
||||
if left.archive_entry() != right.archive_entry()
|
||||
|| left.serialized_file_path() != right.serialized_file_path()
|
||||
|| left.path_id() != right.path_id()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match (left, right) {
|
||||
(UnityOperationTarget::WholeObject { .. }, _)
|
||||
| (_, UnityOperationTarget::WholeObject { .. }) => true,
|
||||
(
|
||||
UnityOperationTarget::Field {
|
||||
field_path: left_path,
|
||||
..
|
||||
},
|
||||
UnityOperationTarget::Field {
|
||||
field_path: right_path,
|
||||
..
|
||||
},
|
||||
) => field_paths_overlap(left_path, right_path),
|
||||
}
|
||||
}
|
||||
|
||||
fn field_paths_overlap(left: &str, right: &str) -> bool {
|
||||
left == right || is_field_path_parent(left, right) || is_field_path_parent(right, left)
|
||||
}
|
||||
|
||||
fn is_field_path_parent(parent: &str, child: &str) -> bool {
|
||||
child
|
||||
.strip_prefix(parent)
|
||||
.is_some_and(|suffix| suffix.starts_with('.') || suffix.starts_with('['))
|
||||
}
|
||||
|
||||
/// Builds a manifest from release-root bytes and ordered operation payloads.
|
||||
@@ -477,13 +591,8 @@ pub fn build_patch_manifest(
|
||||
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 direct_operations = !has_unity_operation && !has_archive_operation;
|
||||
let mut current = source.clone();
|
||||
let operations = file
|
||||
.operations
|
||||
@@ -491,39 +600,58 @@ pub fn build_patch_manifest(
|
||||
.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 direct_operations && operation.source_blake3.is_none() && operation_count > 1 {
|
||||
operation.source_blake3 = Some(blake3_hex(¤t));
|
||||
operation.source_size = Some(current.len() as u64);
|
||||
} else if !direct_operations
|
||||
&& operation_count > 1
|
||||
&& operation.source_blake3.is_none()
|
||||
{
|
||||
if expected_hash != blake3_hex(¤t)
|
||||
|| expected_size != current.len() as u64
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder requires source preconditions for multiple non-direct operations: {}",
|
||||
file.path.display()
|
||||
)));
|
||||
}
|
||||
if direct_operations {
|
||||
if let (Some(expected_hash), Some(expected_size)) =
|
||||
(operation.source_blake3.as_deref(), operation.source_size)
|
||||
{
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder operation source precondition mismatch: {} operation {}",
|
||||
file.path.display(),
|
||||
sequence
|
||||
)));
|
||||
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
|
||||
)));
|
||||
}
|
||||
}
|
||||
current = apply_direct_payload(¤t, &operation.payload)?;
|
||||
} else if operation_count == 1 {
|
||||
if let (Some(expected_hash), Some(expected_size)) =
|
||||
(operation.source_blake3.as_deref(), operation.source_size)
|
||||
{
|
||||
if expected_hash != blake3_hex(&source)
|
||||
|| expected_size != source.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)?;
|
||||
}
|
||||
/*
|
||||
* Non-direct operations are produced by the owning adapter.
|
||||
* Their source preconditions describe adapter-produced
|
||||
* intermediate bytes, which this crate cannot reconstruct.
|
||||
*/
|
||||
operation.sequence = sequence as u32;
|
||||
Ok(operation)
|
||||
})
|
||||
.collect::<crate::Result<Vec<_>>>()?;
|
||||
if !has_unity_operation && !has_archive_operation && current != target {
|
||||
if direct_operations && current != target {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder operations do not produce target bytes: {}",
|
||||
file.path.display()
|
||||
@@ -852,45 +980,145 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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,
|
||||
},
|
||||
];
|
||||
fn validate_patch_manifest_allows_sibling_fields_on_one_unity_object() {
|
||||
let manifest = unity_manifest(vec![
|
||||
unity_string_operation(None, "first", 0),
|
||||
unity_field_operation(None, "second", 1),
|
||||
]);
|
||||
|
||||
validate_patch_manifest(&manifest).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_patch_manifest_rejects_duplicate_unity_field() {
|
||||
let manifest = unity_manifest(vec![
|
||||
unity_string_operation(None, "first", 0),
|
||||
unity_string_operation(None, "first", 1),
|
||||
]);
|
||||
|
||||
assert!(validate_patch_manifest(&manifest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_patch_manifest_rejects_whole_object_and_field_overlap() {
|
||||
let manifest = unity_manifest(vec![
|
||||
unity_text_asset_operation(None, 0),
|
||||
unity_string_operation(None, "first", 1),
|
||||
]);
|
||||
|
||||
assert!(validate_patch_manifest(&manifest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_patch_manifest_rejects_parent_child_field_overlap() {
|
||||
let manifest = unity_manifest(vec![
|
||||
unity_string_operation(None, "root", 0),
|
||||
unity_field_operation(None, "root.child", 1),
|
||||
]);
|
||||
|
||||
assert!(validate_patch_manifest(&manifest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_patch_manifest_applies_the_same_identity_rules_inside_zip_entries() {
|
||||
validate_patch_manifest(&unity_manifest(vec![
|
||||
unity_string_operation(Some("bundles/one.bundle"), "first", 0),
|
||||
unity_field_operation(Some("bundles/one.bundle"), "second", 1),
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
for operations in [
|
||||
vec![
|
||||
unity_string_operation(Some("bundles/one.bundle"), "first", 0),
|
||||
unity_string_operation(Some("bundles/one.bundle"), "first", 1),
|
||||
],
|
||||
vec![
|
||||
unity_text_asset_operation(Some("bundles/one.bundle"), 0),
|
||||
unity_string_operation(Some("bundles/one.bundle"), "first", 1),
|
||||
],
|
||||
vec![
|
||||
unity_string_operation(Some("bundles/one.bundle"), "root", 0),
|
||||
unity_field_operation(Some("bundles/one.bundle"), "root.child", 1),
|
||||
],
|
||||
] {
|
||||
assert!(validate_patch_manifest(&unity_manifest(operations)).is_err());
|
||||
}
|
||||
|
||||
validate_patch_manifest(&unity_manifest(vec![
|
||||
unity_string_operation(Some("bundles/one.bundle"), "first", 0),
|
||||
unity_string_operation(Some("bundles/two.bundle"), "first", 1),
|
||||
]))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn unity_manifest(operations: Vec<PatchManifestOperation>) -> PatchManifest {
|
||||
let mut manifest = manifest_for("bundle", b"source", b"target");
|
||||
manifest.files[0].patch_kind = PatchKind::Mixed;
|
||||
manifest.files[0].operations = operations;
|
||||
manifest
|
||||
}
|
||||
|
||||
fn unity_string_operation(
|
||||
archive_entry: Option<&str>,
|
||||
field_path: &str,
|
||||
sequence: u32,
|
||||
) -> PatchManifestOperation {
|
||||
PatchManifestOperation {
|
||||
sequence,
|
||||
source_blake3: Some(blake3_hex(b"source")),
|
||||
source_size: Some(6),
|
||||
archive_entry: archive_entry.map(str::to_string),
|
||||
payload: PatchManifestOperationPayload::UnityFsStringField {
|
||||
serialized_file_path: "CAB-one".to_string(),
|
||||
path_id: 7,
|
||||
field_path: field_path.to_string(),
|
||||
expected_value: None,
|
||||
replacement: "replacement".to_string(),
|
||||
},
|
||||
provenance: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unity_field_operation(
|
||||
archive_entry: Option<&str>,
|
||||
field_path: &str,
|
||||
sequence: u32,
|
||||
) -> PatchManifestOperation {
|
||||
PatchManifestOperation {
|
||||
sequence,
|
||||
source_blake3: Some(blake3_hex(b"source")),
|
||||
source_size: Some(6),
|
||||
archive_entry: archive_entry.map(str::to_string),
|
||||
payload: PatchManifestOperationPayload::UnityFsField {
|
||||
serialized_file_path: "CAB-one".to_string(),
|
||||
path_id: 7,
|
||||
field_path: field_path.to_string(),
|
||||
expected_value: None,
|
||||
replacement: serde_json::json!({"kind": "string", "value": "replacement"}),
|
||||
},
|
||||
provenance: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unity_text_asset_operation(
|
||||
archive_entry: Option<&str>,
|
||||
sequence: u32,
|
||||
) -> PatchManifestOperation {
|
||||
PatchManifestOperation {
|
||||
sequence,
|
||||
source_blake3: Some(blake3_hex(b"source")),
|
||||
source_size: Some(6),
|
||||
archive_entry: archive_entry.map(str::to_string),
|
||||
payload: PatchManifestOperationPayload::UnityFsTextAsset {
|
||||
serialized_file_path: "CAB-one".to_string(),
|
||||
path_id: 7,
|
||||
expected_name: None,
|
||||
replacement: b"replacement".to_vec(),
|
||||
},
|
||||
provenance: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest {
|
||||
PatchManifest {
|
||||
version: PATCH_MANIFEST_VERSION,
|
||||
|
||||
Reference in New Issue
Block a user