mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-19 12:06:40 +08:00
feat(release):完成双 release 运维闭环
This commit is contained in:
@@ -6,7 +6,7 @@ use bat_assetbundle::{
|
||||
UnitySerializedValue,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
@@ -446,6 +446,30 @@ pub struct LocalizedPatchIntegrity {
|
||||
pub current_points_to_release: bool,
|
||||
}
|
||||
|
||||
/// Read-only contract and artifact verification result for one localized
|
||||
/// release. Contract validity covers the manifest schema and release identity;
|
||||
/// artifact integrity additionally verifies recorded files and UnityFS/ZIP
|
||||
/// semantic replacements.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct LocalizedArtifactIntegrityReport {
|
||||
/// `valid`, `legacy`, `invalid` or `missing`.
|
||||
pub manifest_contract_status: String,
|
||||
/// `valid`, `invalid` or `unavailable`.
|
||||
pub artifact_integrity_status: String,
|
||||
/// Whether all read-only checks passed.
|
||||
pub verified: bool,
|
||||
/// Whether `current` points to this release.
|
||||
pub current_points_to_release: bool,
|
||||
/// Whether the manifest file could be read.
|
||||
pub manifest_available: bool,
|
||||
/// Whether manifest release IDs match the observed state.
|
||||
pub manifest_matches_release: bool,
|
||||
/// First diagnostic, retained for compact callers.
|
||||
pub error: Option<String>,
|
||||
/// All diagnostics from the read-only inspection.
|
||||
pub diagnostics: Vec<String>,
|
||||
}
|
||||
|
||||
/// Persisted manifest for one localized release.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalizedPatchManifest {
|
||||
@@ -1932,6 +1956,292 @@ fn verify_published_localized_release(
|
||||
Ok(integrity)
|
||||
}
|
||||
|
||||
/// Inspects one localized release without changing state, staging, current or
|
||||
/// any repair target.
|
||||
pub fn inspect_localized_release_artifact(
|
||||
official_release_root: &Path,
|
||||
localized_output_root: &Path,
|
||||
localized_release_id: &str,
|
||||
expected_official_release_id: &str,
|
||||
unzip_command: &Path,
|
||||
) -> LocalizedArtifactIntegrityReport {
|
||||
inspect_localized_release_artifact_inner(
|
||||
official_release_root,
|
||||
localized_output_root,
|
||||
localized_release_id,
|
||||
expected_official_release_id,
|
||||
unzip_command,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// Inspects a historical localized release without requiring the channel
|
||||
/// `current` pointer to select it.
|
||||
pub fn inspect_localized_release_artifact_at(
|
||||
official_release_root: &Path,
|
||||
localized_output_root: &Path,
|
||||
localized_release_id: &str,
|
||||
expected_official_release_id: &str,
|
||||
unzip_command: &Path,
|
||||
) -> LocalizedArtifactIntegrityReport {
|
||||
inspect_localized_release_artifact_inner(
|
||||
official_release_root,
|
||||
localized_output_root,
|
||||
localized_release_id,
|
||||
expected_official_release_id,
|
||||
unzip_command,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_safe_release_id(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value != "."
|
||||
&& value != ".."
|
||||
&& !value.contains('/')
|
||||
&& !value.contains('\\')
|
||||
&& !value.contains(':')
|
||||
&& !value.contains('\0')
|
||||
}
|
||||
|
||||
fn inspect_localized_release_artifact_inner(
|
||||
official_release_root: &Path,
|
||||
localized_output_root: &Path,
|
||||
localized_release_id: &str,
|
||||
expected_official_release_id: &str,
|
||||
unzip_command: &Path,
|
||||
require_current_pointer: bool,
|
||||
) -> LocalizedArtifactIntegrityReport {
|
||||
let mut diagnostics = Vec::new();
|
||||
if !is_safe_release_id(localized_release_id) {
|
||||
diagnostics.push(format!(
|
||||
"localized release identity 不安全:{localized_release_id}"
|
||||
));
|
||||
}
|
||||
if !is_safe_release_id(expected_official_release_id) {
|
||||
diagnostics.push(format!(
|
||||
"official release identity 不安全:{expected_official_release_id}"
|
||||
));
|
||||
}
|
||||
if !diagnostics.is_empty() {
|
||||
return LocalizedArtifactIntegrityReport {
|
||||
manifest_contract_status: "invalid".to_string(),
|
||||
artifact_integrity_status: "invalid".to_string(),
|
||||
verified: false,
|
||||
current_points_to_release: false,
|
||||
manifest_available: false,
|
||||
manifest_matches_release: false,
|
||||
error: diagnostics.first().cloned(),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
let version_path = localized_output_root
|
||||
.join(LOCALIZED_VERSIONS_DIR)
|
||||
.join(localized_release_id);
|
||||
let current_path = localized_output_root.join(LOCALIZED_CURRENT_LINK);
|
||||
let current_points_to_release =
|
||||
current_points_to_version(¤t_path, &version_path).unwrap_or(false);
|
||||
let candidate_exists = fs::symlink_metadata(&version_path)
|
||||
.map(|metadata| metadata.is_dir())
|
||||
.unwrap_or(false);
|
||||
if !candidate_exists {
|
||||
diagnostics.push(format!(
|
||||
"localized release 目录不存在:{}",
|
||||
version_path.display()
|
||||
));
|
||||
return LocalizedArtifactIntegrityReport {
|
||||
manifest_contract_status: "missing".to_string(),
|
||||
artifact_integrity_status: "unavailable".to_string(),
|
||||
verified: false,
|
||||
current_points_to_release,
|
||||
manifest_available: false,
|
||||
manifest_matches_release: false,
|
||||
error: diagnostics.first().cloned(),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
if require_current_pointer && !current_points_to_release {
|
||||
diagnostics.push(format!(
|
||||
"localized current 未指向 release:current={} version={}",
|
||||
current_path.display(),
|
||||
version_path.display()
|
||||
));
|
||||
}
|
||||
if let Err(error) = ensure_safe_directory_path(&version_path, "汉化 release") {
|
||||
diagnostics.push(error.to_string());
|
||||
}
|
||||
|
||||
let manifest_available = fs::symlink_metadata(version_path.join(LOCALIZED_PATCH_MANIFEST_FILE))
|
||||
.map(|metadata| !metadata.file_type().is_symlink())
|
||||
.unwrap_or(false);
|
||||
let manifest = match read_localized_patch_manifest_at(&version_path) {
|
||||
Ok(Some(manifest)) => manifest,
|
||||
Ok(None) => {
|
||||
diagnostics.push(format!(
|
||||
"缺少汉化 patch manifest:{}",
|
||||
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
|
||||
));
|
||||
return LocalizedArtifactIntegrityReport {
|
||||
manifest_contract_status: "missing".to_string(),
|
||||
artifact_integrity_status: "invalid".to_string(),
|
||||
verified: false,
|
||||
current_points_to_release,
|
||||
manifest_available,
|
||||
manifest_matches_release: false,
|
||||
error: diagnostics.first().cloned(),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
Err(error) => {
|
||||
diagnostics.push(error.to_string());
|
||||
return LocalizedArtifactIntegrityReport {
|
||||
manifest_contract_status: "invalid".to_string(),
|
||||
artifact_integrity_status: "invalid".to_string(),
|
||||
verified: false,
|
||||
current_points_to_release,
|
||||
manifest_available,
|
||||
manifest_matches_release: false,
|
||||
error: diagnostics.first().cloned(),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
};
|
||||
let wrapper_matches_release = manifest.official_release_id == expected_official_release_id
|
||||
&& manifest.localized_release_id == localized_release_id;
|
||||
if !wrapper_matches_release {
|
||||
diagnostics.push(format!(
|
||||
"localized manifest identity 不匹配:official={} localized={}",
|
||||
manifest.official_release_id, manifest.localized_release_id
|
||||
));
|
||||
}
|
||||
let generic_matches_wrapper = manifest.patch_manifest.as_ref().is_none_or(|generic| {
|
||||
generic.source_version == manifest.official_release_id
|
||||
&& generic.target_version == manifest.localized_release_id
|
||||
});
|
||||
if !generic_matches_wrapper {
|
||||
diagnostics.push(
|
||||
"generic manifest source_version/target_version 与 localized wrapper 不一致"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let manifest_matches_release = wrapper_matches_release && generic_matches_wrapper;
|
||||
let manifest_contract_status = if let Some(generic) = manifest.patch_manifest.as_ref() {
|
||||
match bat_patch::validate_patch_manifest(generic) {
|
||||
Ok(()) if manifest_matches_release => "valid",
|
||||
Ok(()) => "invalid",
|
||||
Err(error) => {
|
||||
diagnostics.push(format!("generic manifest schema 无效:{error}"));
|
||||
"invalid"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"legacy"
|
||||
};
|
||||
if manifest_contract_status == "invalid" {
|
||||
return LocalizedArtifactIntegrityReport {
|
||||
manifest_contract_status: manifest_contract_status.to_string(),
|
||||
artifact_integrity_status: "invalid".to_string(),
|
||||
verified: false,
|
||||
current_points_to_release,
|
||||
manifest_available,
|
||||
manifest_matches_release,
|
||||
error: diagnostics.first().cloned(),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
if let Err(error) = verify_patch_manifest_files(
|
||||
official_release_root,
|
||||
&version_path,
|
||||
&manifest,
|
||||
unzip_command,
|
||||
) {
|
||||
diagnostics.push(error.to_string());
|
||||
}
|
||||
if manifest.patch_manifest.is_some() {
|
||||
match crate::official_download::read_download_manifest_at(official_release_root) {
|
||||
Ok(Some(download_manifest)) => {
|
||||
if let Err(error) = verify_localized_resource_root(
|
||||
official_release_root,
|
||||
&version_path,
|
||||
&manifest,
|
||||
&download_manifest,
|
||||
) {
|
||||
diagnostics.push(error.to_string());
|
||||
}
|
||||
}
|
||||
Ok(None) => diagnostics.push(format!(
|
||||
"缺少官方 download manifest,无法完成 generic release 全量完整性检查:{}",
|
||||
official_release_root.display()
|
||||
)),
|
||||
Err(error) => diagnostics.push(error),
|
||||
}
|
||||
}
|
||||
let verified = diagnostics.is_empty()
|
||||
&& (!require_current_pointer || current_points_to_release)
|
||||
&& manifest_matches_release;
|
||||
LocalizedArtifactIntegrityReport {
|
||||
manifest_contract_status: manifest_contract_status.to_string(),
|
||||
artifact_integrity_status: if verified {
|
||||
"valid".to_string()
|
||||
} else {
|
||||
"invalid".to_string()
|
||||
},
|
||||
verified,
|
||||
current_points_to_release,
|
||||
manifest_available,
|
||||
manifest_matches_release,
|
||||
error: diagnostics.first().cloned(),
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_localized_resource_root(
|
||||
official_release_root: &Path,
|
||||
localized_release_root: &Path,
|
||||
localized_manifest: &LocalizedPatchManifest,
|
||||
download_manifest: &crate::official_download::OfficialDownloadManifest,
|
||||
) -> anyhow::Result<()> {
|
||||
let changed_paths = localized_manifest
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| file.path.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
for entry in download_manifest.entries.values() {
|
||||
let official_path = official_release_root.join(&entry.destination);
|
||||
let localized_path = localized_release_root.join(&entry.destination);
|
||||
ensure_path_within_root(official_release_root, &official_path)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
ensure_path_within_root(localized_release_root, &localized_path)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
ensure_safe_file_target(official_release_root, &official_path, "官方 release 文件")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
ensure_safe_file_target(localized_release_root, &localized_path, "汉化 release 文件")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let official = fs::read(&official_path)?;
|
||||
if official.len() as u64 != entry.bytes
|
||||
|| blake3::hash(&official).to_hex().to_string() != entry.blake3
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"官方 source 文件完整性失败:{}",
|
||||
entry.destination
|
||||
));
|
||||
}
|
||||
let localized = fs::read(&localized_path)?;
|
||||
if changed_paths.contains(entry.destination.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if localized.len() as u64 != entry.bytes
|
||||
|| blake3::hash(&localized).to_hex().to_string() != entry.blake3
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"汉化 release 未变更文件完整性失败:{}",
|
||||
entry.destination
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_patch_manifest_files(
|
||||
official_release_root: &Path,
|
||||
localized_release_root: &Path,
|
||||
@@ -2463,7 +2773,10 @@ fn release_id_from_current_target(target: &Path) -> Option<String> {
|
||||
Some(std::path::Component::Normal(root)),
|
||||
Some(std::path::Component::Normal(release_id)),
|
||||
None,
|
||||
) if root == LOCALIZED_VERSIONS_DIR => release_id.to_str().map(str::to_string),
|
||||
) if root == LOCALIZED_VERSIONS_DIR => {
|
||||
let release_id = release_id.to_str()?;
|
||||
is_safe_release_id(release_id).then_some(release_id.to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2567,6 +2880,8 @@ fn validate_config(config: &LocalizedPatchConfig) -> Result<(), String> {
|
||||
if release_id.is_empty()
|
||||
|| release_id.contains('/')
|
||||
|| release_id.contains('\\')
|
||||
|| release_id.contains(':')
|
||||
|| release_id.contains('\0')
|
||||
|| release_id == "."
|
||||
|| release_id == ".."
|
||||
{
|
||||
@@ -3440,6 +3755,26 @@ mod tests {
|
||||
assert!(report.publish_allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_inspection_rejects_unsafe_release_identity_before_reading_paths() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let report = inspect_localized_release_artifact_at(
|
||||
&temp.path().join("official"),
|
||||
&temp.path().join("localized"),
|
||||
"../outside",
|
||||
"official-1",
|
||||
Path::new("unzip"),
|
||||
);
|
||||
|
||||
assert_eq!(report.manifest_contract_status, "invalid");
|
||||
assert_eq!(report.artifact_integrity_status, "invalid");
|
||||
assert!(!report.verified);
|
||||
assert!(report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.contains("identity 不安全")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_patch_publish_cleans_staging_and_unpublished_version() {
|
||||
|
||||
Reference in New Issue
Block a user