feat(release):完成双 release 运维闭环
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-12 11:08:04 +08:00
parent 8a77502272
commit 8d57a63697
27 changed files with 3635 additions and 222 deletions
+315 -87
View File
@@ -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(&current));
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(&current));
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(&current)
|| 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(&current)
|| 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(&current, &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(&current, &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,