mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
feat(release):完成双 release 运维闭环
This commit is contained in:
@@ -7,30 +7,33 @@ use bat_core::{ApiError, ErrorCode};
|
||||
use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY;
|
||||
use bat_infrastructure::{
|
||||
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
|
||||
apply_unityfs_text_asset_patch_file, changed_endpoint_urls,
|
||||
completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench,
|
||||
gc_orphan_staging_with_cas_root, get_translation_entry, lexical_absolute,
|
||||
apply_unityfs_text_asset_patch_file, build_release_list, build_release_status,
|
||||
changed_endpoint_urls, cleanup_releases, completed_worker_translation_workbench,
|
||||
diff_extended_snapshot, export_translation_workbench, gc_orphan_staging_with_cas_root,
|
||||
get_translation_entry, inspect_localized_release_artifact, lexical_absolute,
|
||||
localized_patch_operations_with_glossary_path, open_append_file, read_download_manifest_at,
|
||||
read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state,
|
||||
read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench,
|
||||
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation,
|
||||
set_translation_checked_with_glossary_path, unset_translation, validate_output_root,
|
||||
validate_runtime_state_dir, validate_translation_workbench_with_glossary_path,
|
||||
write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode,
|
||||
LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
|
||||
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy,
|
||||
select_release_distribution, set_translation, set_translation_checked_with_glossary_path,
|
||||
unset_translation, validate_output_root, validate_runtime_state_dir,
|
||||
validate_translation_workbench_with_glossary_path, write_file_atomic,
|
||||
write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig,
|
||||
LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
|
||||
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
|
||||
OfficialParseConfig, OfficialResourceHashVerification, OfficialResourceVerification,
|
||||
OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery,
|
||||
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
|
||||
OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams,
|
||||
PatchApplyReport, ReleaseFlowStatusCode, RepackReport, SqliteResourceRepository,
|
||||
SqliteTranslationTaskRepository, TranslationProviderKind, TranslationTaskStatus,
|
||||
TranslationWorkerConfig, UnityFsFieldPatchParams, UnityFsPatchReport,
|
||||
UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE,
|
||||
DEFAULT_TRANSLATION_CONCURRENCY, DEFAULT_TRANSLATION_LEASE_SECONDS,
|
||||
DEFAULT_TRANSLATION_MAX_ATTEMPTS, DEFAULT_TRANSLATION_RETRY_BACKOFF, LOCALIZED_CURRENT_LINK,
|
||||
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
||||
PatchApplyReport, ReleaseCleanupParams, ReleaseDistributionParams, ReleaseFlowStatusCode,
|
||||
ReleaseListParams, RepackReport, SqliteResourceRepository, SqliteTranslationTaskRepository,
|
||||
TranslationProviderKind, TranslationTaskStatus, TranslationWorkerConfig,
|
||||
UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams,
|
||||
UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE, DEFAULT_TRANSLATION_CONCURRENCY,
|
||||
DEFAULT_TRANSLATION_LEASE_SECONDS, DEFAULT_TRANSLATION_MAX_ATTEMPTS,
|
||||
DEFAULT_TRANSLATION_RETRY_BACKOFF, LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE,
|
||||
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
||||
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR,
|
||||
LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, MAX_TRANSLATION_CONCURRENCY,
|
||||
MIN_DOWNLOAD_CONCURRENCY, MIN_TRANSLATION_CONCURRENCY, OFFICIAL_PARSE_CACHE_FILE,
|
||||
@@ -1195,6 +1198,10 @@ const RPC_METHOD_GLOSSARY_DIAGNOSE: &str = "translation.glossary.diagnose";
|
||||
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
|
||||
const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish";
|
||||
const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback";
|
||||
const RPC_METHOD_RELEASE_STATUS: &str = "release.status";
|
||||
const RPC_METHOD_RELEASE_LIST: &str = "release.list";
|
||||
const RPC_METHOD_RELEASE_DISTRIBUTION: &str = "release.distribution";
|
||||
const RPC_METHOD_RELEASE_CLEANUP: &str = "release.cleanup";
|
||||
const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status";
|
||||
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
|
||||
const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff";
|
||||
@@ -2371,6 +2378,94 @@ fn dispatch_rpc_method(
|
||||
Err(error) => rpc_envelope_error(request_id, error),
|
||||
}
|
||||
}
|
||||
RPC_METHOD_RELEASE_STATUS => {
|
||||
let _sync_guard = tasks
|
||||
.sync_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
rpc_envelope_from_result(
|
||||
request_id,
|
||||
RPC_METHOD_RELEASE_STATUS,
|
||||
build_release_status(
|
||||
&tasks.base_config.output_root,
|
||||
&tasks.base_config.localized_output_root,
|
||||
&tasks.base_config.unzip_command,
|
||||
)
|
||||
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
|
||||
)
|
||||
}
|
||||
RPC_METHOD_RELEASE_LIST => {
|
||||
let params = match rpc_optional_struct_params::<ReleaseListParams>(
|
||||
request.params.as_ref(),
|
||||
RPC_METHOD_RELEASE_LIST,
|
||||
) {
|
||||
Ok(params) => params,
|
||||
Err(error) => return rpc_envelope_error(request_id, error),
|
||||
};
|
||||
let _sync_guard = tasks
|
||||
.sync_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
rpc_envelope_from_result(
|
||||
request_id,
|
||||
RPC_METHOD_RELEASE_LIST,
|
||||
build_release_list(
|
||||
&tasks.base_config.output_root,
|
||||
&tasks.base_config.localized_output_root,
|
||||
¶ms,
|
||||
&tasks.base_config.unzip_command,
|
||||
),
|
||||
)
|
||||
}
|
||||
RPC_METHOD_RELEASE_DISTRIBUTION => {
|
||||
let params = match rpc_optional_struct_params::<ReleaseDistributionParams>(
|
||||
request.params.as_ref(),
|
||||
RPC_METHOD_RELEASE_DISTRIBUTION,
|
||||
) {
|
||||
Ok(params) => params,
|
||||
Err(error) => return rpc_envelope_error(request_id, error),
|
||||
};
|
||||
let _sync_guard = tasks
|
||||
.sync_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
rpc_envelope_from_result(
|
||||
request_id,
|
||||
RPC_METHOD_RELEASE_DISTRIBUTION,
|
||||
select_release_distribution(
|
||||
&tasks.base_config.output_root,
|
||||
&tasks.base_config.localized_output_root,
|
||||
¶ms,
|
||||
&tasks.base_config.unzip_command,
|
||||
)
|
||||
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
|
||||
)
|
||||
}
|
||||
RPC_METHOD_RELEASE_CLEANUP => {
|
||||
let params = match rpc_optional_struct_params::<ReleaseCleanupParams>(
|
||||
request.params.as_ref(),
|
||||
RPC_METHOD_RELEASE_CLEANUP,
|
||||
) {
|
||||
Ok(params) => params,
|
||||
Err(error) => return rpc_envelope_error(request_id, error),
|
||||
};
|
||||
let _sync_guard = tasks
|
||||
.sync_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
rpc_envelope_from_result(
|
||||
request_id,
|
||||
RPC_METHOD_RELEASE_CLEANUP,
|
||||
cleanup_releases(
|
||||
&tasks.base_config.output_root,
|
||||
&tasks.base_config.localized_output_root,
|
||||
&tasks.base_config.effective_import_cas_root(),
|
||||
¶ms,
|
||||
&tasks.base_config.unzip_command,
|
||||
)
|
||||
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
|
||||
)
|
||||
}
|
||||
RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result(
|
||||
request_id,
|
||||
"catalog.status",
|
||||
@@ -3281,7 +3376,12 @@ fn build_localized_status_report(
|
||||
let mut patch_manifest_path = None;
|
||||
let mut patch_manifest_available = false;
|
||||
let mut patch_manifest_matches_release = false;
|
||||
let mut patch_manifest_integrity_status = None;
|
||||
let mut patch_manifest_contract_status: Option<String> = None;
|
||||
let mut patch_manifest_integrity_status: Option<String> = None;
|
||||
let mut artifact_integrity_status = None;
|
||||
let mut artifact_integrity_verified = false;
|
||||
let mut artifact_integrity_error = None;
|
||||
let mut artifact_integrity_diagnostics = Vec::new();
|
||||
let mut patch_manifest_source_version = None;
|
||||
let mut patch_manifest_target_version = None;
|
||||
let mut patch_file_count = None;
|
||||
@@ -3321,69 +3421,161 @@ fn build_localized_status_report(
|
||||
}
|
||||
if localized_state.status == "localized" && matches_current_official_release {
|
||||
if let Some(release_id) = localized_state.current_release_id.as_deref() {
|
||||
let candidate = localized_root.join(LOCALIZED_VERSIONS_DIR).join(release_id);
|
||||
current_points_to_published_version =
|
||||
localized_current_points_to(¤t_path, &candidate);
|
||||
let manifest_path = candidate.join(LOCALIZED_PATCH_MANIFEST_FILE);
|
||||
patch_manifest_path = Some(manifest_path);
|
||||
if let Some(manifest) = read_localized_patch_manifest_at(&candidate)? {
|
||||
patch_manifest_available = true;
|
||||
patch_manifest_matches_release = official_version_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| manifest.official_release_id == id)
|
||||
&& manifest.localized_release_id == release_id;
|
||||
patch_file_count = Some(manifest.file_count);
|
||||
patch_text_asset_operation_count = Some(manifest.text_asset_operation_count);
|
||||
rollback_previous_current_target = manifest.rollback.previous_current_target;
|
||||
if let Some(generic_manifest) = manifest.patch_manifest.as_ref() {
|
||||
patch_manifest_source_version =
|
||||
Some(generic_manifest.source_version.clone());
|
||||
patch_manifest_target_version =
|
||||
Some(generic_manifest.target_version.clone());
|
||||
let mut operation_count = 0usize;
|
||||
for file in &generic_manifest.files {
|
||||
for operation in &file.operations {
|
||||
operation_count += 1;
|
||||
let key = match operation.patch_kind() {
|
||||
bat_patch::PatchKind::Binary => "binary",
|
||||
bat_patch::PatchKind::Json => "json",
|
||||
bat_patch::PatchKind::Text => "text",
|
||||
bat_patch::PatchKind::UnityFsTextAsset => "unityfs_text_asset",
|
||||
bat_patch::PatchKind::UnityFsStringField => {
|
||||
"unityfs_string_field"
|
||||
let official_id_is_safe = official_version_id
|
||||
.as_deref()
|
||||
.is_some_and(is_safe_release_id);
|
||||
if !is_safe_release_id(release_id) || !official_id_is_safe {
|
||||
let diagnostic = if !is_safe_release_id(release_id) {
|
||||
format!("localized current release identity 不安全:{release_id}")
|
||||
} else {
|
||||
format!(
|
||||
"official current release identity 不安全:{}",
|
||||
official_version_id.as_deref().unwrap_or_default()
|
||||
)
|
||||
};
|
||||
localized_release_status = "degraded";
|
||||
flow_status_code = ReleaseFlowStatusCode::LocalizedDegraded;
|
||||
patch_manifest_contract_status = Some("invalid".to_string());
|
||||
patch_manifest_integrity_status = Some("invalid".to_string());
|
||||
artifact_integrity_status = Some("invalid".to_string());
|
||||
artifact_integrity_error = Some(diagnostic.clone());
|
||||
artifact_integrity_diagnostics.push(diagnostic);
|
||||
} else {
|
||||
let candidate = localized_root.join(LOCALIZED_VERSIONS_DIR).join(release_id);
|
||||
current_points_to_published_version =
|
||||
localized_current_points_to(¤t_path, &candidate);
|
||||
let manifest_path = candidate.join(LOCALIZED_PATCH_MANIFEST_FILE);
|
||||
patch_manifest_path = Some(manifest_path.clone());
|
||||
match read_localized_patch_manifest_at(&candidate) {
|
||||
Ok(Some(manifest)) => {
|
||||
patch_manifest_available = true;
|
||||
patch_manifest_matches_release = official_version_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| manifest.official_release_id == id)
|
||||
&& manifest.localized_release_id == release_id;
|
||||
patch_file_count = Some(manifest.file_count);
|
||||
patch_text_asset_operation_count =
|
||||
Some(manifest.text_asset_operation_count);
|
||||
rollback_previous_current_target =
|
||||
manifest.rollback.previous_current_target;
|
||||
if let Some(generic_manifest) = manifest.patch_manifest.as_ref() {
|
||||
patch_manifest_source_version =
|
||||
Some(generic_manifest.source_version.clone());
|
||||
patch_manifest_target_version =
|
||||
Some(generic_manifest.target_version.clone());
|
||||
let mut operation_count = 0usize;
|
||||
for file in &generic_manifest.files {
|
||||
for operation in &file.operations {
|
||||
operation_count += 1;
|
||||
let key = match operation.patch_kind() {
|
||||
bat_patch::PatchKind::Binary => "binary",
|
||||
bat_patch::PatchKind::Json => "json",
|
||||
bat_patch::PatchKind::Text => "text",
|
||||
bat_patch::PatchKind::UnityFsTextAsset => {
|
||||
"unityfs_text_asset"
|
||||
}
|
||||
bat_patch::PatchKind::UnityFsStringField => {
|
||||
"unityfs_string_field"
|
||||
}
|
||||
bat_patch::PatchKind::UnityFsField => "unityfs_field",
|
||||
bat_patch::PatchKind::Mixed => "mixed",
|
||||
}
|
||||
.to_string();
|
||||
let count = patch_kind_counts
|
||||
.entry(key)
|
||||
.or_insert_with(|| serde_json::json!(0));
|
||||
*count = serde_json::json!(
|
||||
count.as_u64().unwrap_or_default() + 1
|
||||
);
|
||||
}
|
||||
bat_patch::PatchKind::UnityFsField => "unityfs_field",
|
||||
bat_patch::PatchKind::Mixed => "mixed",
|
||||
}
|
||||
.to_string();
|
||||
let count = patch_kind_counts
|
||||
.entry(key)
|
||||
.or_insert_with(|| serde_json::json!(0));
|
||||
*count = serde_json::json!(count.as_u64().unwrap_or_default() + 1);
|
||||
patch_operation_count = Some(operation_count);
|
||||
patch_manifest_contract_status = Some(
|
||||
if bat_patch::validate_patch_manifest(generic_manifest).is_ok()
|
||||
{
|
||||
"valid"
|
||||
} else {
|
||||
"invalid"
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
patch_manifest_integrity_status =
|
||||
patch_manifest_contract_status.clone();
|
||||
patch_manifest_matches_release &= generic_manifest.source_version
|
||||
== manifest.official_release_id
|
||||
&& generic_manifest.target_version
|
||||
== manifest.localized_release_id;
|
||||
} else {
|
||||
patch_manifest_contract_status = Some("legacy".to_string());
|
||||
patch_manifest_integrity_status = Some("legacy".to_string());
|
||||
}
|
||||
}
|
||||
patch_operation_count = Some(operation_count);
|
||||
patch_manifest_integrity_status = Some(
|
||||
if bat_patch::validate_patch_manifest(generic_manifest).is_ok() {
|
||||
"valid"
|
||||
} else {
|
||||
"invalid"
|
||||
},
|
||||
);
|
||||
patch_manifest_matches_release &= generic_manifest.source_version
|
||||
== manifest.official_release_id
|
||||
&& generic_manifest.target_version == manifest.localized_release_id;
|
||||
} else {
|
||||
patch_manifest_integrity_status = Some("legacy");
|
||||
Ok(None) => {
|
||||
patch_manifest_contract_status = Some("missing".to_string());
|
||||
patch_manifest_integrity_status = Some("missing".to_string());
|
||||
}
|
||||
Err(error) => {
|
||||
patch_manifest_available = fs::symlink_metadata(&manifest_path).is_ok();
|
||||
patch_manifest_contract_status = Some("invalid".to_string());
|
||||
patch_manifest_integrity_status = Some("invalid".to_string());
|
||||
artifact_integrity_error = Some(error.to_string());
|
||||
artifact_integrity_diagnostics.push(error.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(official_root) = status_file
|
||||
.as_ref()
|
||||
.map(|status| {
|
||||
status
|
||||
.resource_output_root
|
||||
.join("versions")
|
||||
.join(official_version_id.as_deref().unwrap_or_default())
|
||||
})
|
||||
.or_else(|| {
|
||||
Some(
|
||||
base_config
|
||||
.output_root
|
||||
.join("versions")
|
||||
.join(official_version_id.as_deref().unwrap_or_default()),
|
||||
)
|
||||
})
|
||||
{
|
||||
let artifact = inspect_localized_release_artifact(
|
||||
&official_root,
|
||||
&localized_root,
|
||||
release_id,
|
||||
official_version_id.as_deref().unwrap_or_default(),
|
||||
&base_config.unzip_command,
|
||||
);
|
||||
artifact_integrity_status =
|
||||
Some(artifact.artifact_integrity_status.clone());
|
||||
artifact_integrity_verified = artifact.verified;
|
||||
artifact_integrity_error = artifact.error.clone();
|
||||
artifact_integrity_diagnostics = artifact.diagnostics.clone();
|
||||
current_points_to_published_version = artifact.current_points_to_release;
|
||||
if artifact.manifest_available {
|
||||
patch_manifest_available = true;
|
||||
}
|
||||
if !artifact.manifest_contract_status.is_empty() {
|
||||
patch_manifest_contract_status =
|
||||
Some(artifact.manifest_contract_status);
|
||||
patch_manifest_integrity_status =
|
||||
patch_manifest_contract_status.clone();
|
||||
}
|
||||
patch_manifest_matches_release = artifact.manifest_matches_release;
|
||||
}
|
||||
if candidate.is_dir()
|
||||
&& current_points_to_published_version
|
||||
&& patch_manifest_matches_release
|
||||
&& artifact_integrity_verified
|
||||
{
|
||||
localized_release_status = "localized";
|
||||
flow_status_code = ReleaseFlowStatusCode::LocalizedPublished;
|
||||
published_version_path = Some(candidate);
|
||||
} else if candidate.is_dir() && current_points_to_published_version {
|
||||
localized_release_status = "degraded";
|
||||
flow_status_code = ReleaseFlowStatusCode::LocalizedDegraded;
|
||||
published_version_path = Some(candidate);
|
||||
}
|
||||
}
|
||||
if candidate.is_dir()
|
||||
&& current_points_to_published_version
|
||||
&& patch_manifest_matches_release
|
||||
{
|
||||
localized_release_status = "localized";
|
||||
flow_status_code = ReleaseFlowStatusCode::LocalizedPublished;
|
||||
published_version_path = Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3413,7 +3605,12 @@ fn build_localized_status_report(
|
||||
"patch_manifest_path": patch_manifest_path,
|
||||
"patch_manifest_available": patch_manifest_available,
|
||||
"patch_manifest_matches_release": patch_manifest_matches_release,
|
||||
"patch_manifest_contract_status": patch_manifest_contract_status,
|
||||
"patch_manifest_integrity_status": patch_manifest_integrity_status,
|
||||
"artifact_integrity_status": artifact_integrity_status,
|
||||
"artifact_integrity_verified": artifact_integrity_verified,
|
||||
"artifact_integrity_error": artifact_integrity_error,
|
||||
"artifact_integrity_diagnostics": artifact_integrity_diagnostics,
|
||||
"patch_manifest_source_version": patch_manifest_source_version,
|
||||
"patch_manifest_target_version": patch_manifest_target_version,
|
||||
"patch_file_count": patch_file_count,
|
||||
@@ -3442,6 +3639,16 @@ fn localized_current_points_to(current_path: &Path, version_path: &Path) -> bool
|
||||
resolved == version_path
|
||||
}
|
||||
|
||||
fn is_safe_release_id(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value != "."
|
||||
&& value != ".."
|
||||
&& !value.contains('/')
|
||||
&& !value.contains('\\')
|
||||
&& !value.contains(':')
|
||||
&& !value.contains('\0')
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_json_rpc_response(
|
||||
stream: &mut UnixStream,
|
||||
|
||||
@@ -4226,6 +4226,75 @@ fn dispatch_catalog_versions_lists_history() {
|
||||
assert!(value["data"]["failed"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dispatch_release_rpc_exposes_dual_release_queries_and_safe_cleanup_plan() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("output");
|
||||
write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
|
||||
let localized_root = temp.path().join("localized");
|
||||
let tasks = test_task_context_with_config(OfficialUpdateConfig {
|
||||
output_root: output_root.clone(),
|
||||
localized_output_root: localized_root.clone(),
|
||||
..OfficialUpdateConfig::default()
|
||||
});
|
||||
|
||||
let status = dispatch_rpc_method(
|
||||
&rpc_request("release.status", None),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&tasks,
|
||||
"req-release-status".to_string(),
|
||||
);
|
||||
let status = serde_json::to_value(status).unwrap();
|
||||
assert_eq!(status["ok"], true);
|
||||
assert_eq!(status["data"]["default_distribution_channel"], "official");
|
||||
assert_eq!(status["data"]["official_current_release_id"], "v-current");
|
||||
|
||||
let list = dispatch_rpc_method(
|
||||
&rpc_request(
|
||||
"release.list",
|
||||
Some(serde_json::json!({"channel": "official"})),
|
||||
),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&tasks,
|
||||
"req-release-list".to_string(),
|
||||
);
|
||||
let list = serde_json::to_value(list).unwrap();
|
||||
assert_eq!(list["ok"], true);
|
||||
assert_eq!(list["data"]["channel"], "official");
|
||||
assert_eq!(list["data"]["releases"][0]["channel"], "official");
|
||||
|
||||
let distribution = dispatch_rpc_method(
|
||||
&rpc_request(
|
||||
"release.distribution",
|
||||
Some(serde_json::json!({"channel": "localized"})),
|
||||
),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&tasks,
|
||||
"req-release-distribution".to_string(),
|
||||
);
|
||||
let distribution = serde_json::to_value(distribution).unwrap();
|
||||
assert_eq!(distribution["ok"], true);
|
||||
assert_eq!(distribution["data"]["available"], false);
|
||||
assert_eq!(distribution["data"]["channel"], "localized");
|
||||
|
||||
let cleanup = dispatch_rpc_method(
|
||||
&rpc_request("release.cleanup", None),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&tasks,
|
||||
"req-release-cleanup".to_string(),
|
||||
);
|
||||
let cleanup = serde_json::to_value(cleanup).unwrap();
|
||||
assert_eq!(cleanup["ok"], true);
|
||||
assert_eq!(cleanup["data"]["execute"], false);
|
||||
assert!(cleanup["data"]["plan_id"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_catalog_diff_reports_bundle_change() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
@@ -5086,7 +5155,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("output");
|
||||
write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
|
||||
let official_version = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
|
||||
let localized_root = temp.path().join("localized");
|
||||
let localized_version = localized_root
|
||||
.join(LOCALIZED_VERSIONS_DIR)
|
||||
@@ -5097,6 +5166,8 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
||||
localized_root.join(LOCALIZED_CURRENT_LINK),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(official_version.join("data.bin"), b"official").unwrap();
|
||||
fs::write(localized_version.join("data.bin"), b"localized").unwrap();
|
||||
fs::write(
|
||||
localized_root.join(LOCALIZED_VERSION_STATE_FILE),
|
||||
serde_json::to_vec(&bat_infrastructure::LocalizedVersionState {
|
||||
@@ -5117,9 +5188,18 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
||||
official_release_id: "v-current".to_string(),
|
||||
localized_release_id: "v-current".to_string(),
|
||||
generated_unix_seconds: 124,
|
||||
file_count: 0,
|
||||
file_count: 1,
|
||||
text_asset_operation_count: 0,
|
||||
files: Vec::new(),
|
||||
files: vec![bat_infrastructure::LocalizedPatchFile {
|
||||
path: "data.bin".to_string(),
|
||||
original_blake3: blake3::hash(b"official").to_hex().to_string(),
|
||||
localized_blake3: blake3::hash(b"localized").to_hex().to_string(),
|
||||
original_bytes: 8,
|
||||
localized_bytes: 9,
|
||||
byte_delta: 1,
|
||||
text_asset_operations: Vec::new(),
|
||||
operations: Vec::new(),
|
||||
}],
|
||||
patch_manifest: None,
|
||||
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||
previous_current_target: None,
|
||||
@@ -5152,7 +5232,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
||||
assert_eq!(value["data"]["current_points_to_published_version"], true);
|
||||
assert_eq!(value["data"]["patch_manifest_available"], true);
|
||||
assert_eq!(value["data"]["patch_manifest_matches_release"], true);
|
||||
assert_eq!(value["data"]["patch_file_count"], 0);
|
||||
assert_eq!(value["data"]["patch_file_count"], 1);
|
||||
assert_eq!(value["data"]["patch_text_asset_operation_count"], 0);
|
||||
assert_eq!(
|
||||
value["data"]["translation_workflow_status"],
|
||||
@@ -5162,6 +5242,23 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
||||
value["data"]["published_version_path"].as_str().unwrap(),
|
||||
localized_version.to_string_lossy()
|
||||
);
|
||||
assert_eq!(value["data"]["patch_manifest_contract_status"], "legacy");
|
||||
assert_eq!(value["data"]["artifact_integrity_status"], "valid");
|
||||
|
||||
fs::write(localized_version.join("data.bin"), b"corrupt").unwrap();
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request("localized.status", None),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&tasks,
|
||||
"req-loc-corrupt".to_string(),
|
||||
);
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], true);
|
||||
assert_eq!(value["data"]["status_code"], "localized.degraded");
|
||||
assert_eq!(value["data"]["localized_release_status"], "degraded");
|
||||
assert_eq!(value["data"]["artifact_integrity_status"], "invalid");
|
||||
assert_eq!(value["data"]["artifact_integrity_verified"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod official_update;
|
||||
pub mod patch_ops;
|
||||
pub mod path_security;
|
||||
pub mod release_flow;
|
||||
pub mod release_ops;
|
||||
pub mod resources;
|
||||
pub mod translation_memory;
|
||||
pub mod translation_tasks;
|
||||
@@ -53,15 +54,16 @@ pub use import::{
|
||||
ResourceImportService,
|
||||
};
|
||||
pub use localized_patch::{
|
||||
inspect_localized_release_artifact, inspect_localized_release_artifact_at,
|
||||
mark_localized_manual_proofreading, read_localized_patch_manifest_at,
|
||||
read_localized_version_state, write_localized_version_state, LocalizedFieldPatch,
|
||||
LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput, LocalizedPatchIntegrity,
|
||||
LocalizedPatchManifest, LocalizedPatchOperation, LocalizedPatchOperationMetadata,
|
||||
LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService,
|
||||
LocalizedRollbackReport, LocalizedStringFieldPatch, LocalizedTextAssetPatch,
|
||||
LocalizedTranslationWorkflowReport, LocalizedVersionState, LOCALIZED_CURRENT_LINK,
|
||||
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR,
|
||||
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
||||
read_localized_version_state, write_localized_version_state, LocalizedArtifactIntegrityReport,
|
||||
LocalizedFieldPatch, LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput,
|
||||
LocalizedPatchIntegrity, LocalizedPatchManifest, LocalizedPatchOperation,
|
||||
LocalizedPatchOperationMetadata, LocalizedPatchReport, LocalizedPatchRollbackInfo,
|
||||
LocalizedPatchService, LocalizedRollbackReport, LocalizedStringFieldPatch,
|
||||
LocalizedTextAssetPatch, LocalizedTranslationWorkflowReport, LocalizedVersionState,
|
||||
LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION,
|
||||
LOCALIZED_STAGING_DIR, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
||||
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR,
|
||||
LOCALIZED_VERSION_STATE_FILE, LOCALIZED_VERSION_STATE_VERSION,
|
||||
};
|
||||
@@ -145,6 +147,11 @@ pub use path_security::{
|
||||
validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE,
|
||||
};
|
||||
pub use release_flow::ReleaseFlowStatusCode;
|
||||
pub use release_ops::{
|
||||
build_release_list, build_release_status, cleanup_releases, select_release_distribution,
|
||||
ReleaseCleanupParams, ReleaseCleanupReport, ReleaseDistributionEntry, ReleaseDistributionPage,
|
||||
ReleaseDistributionParams, ReleaseListParams, ReleaseStatusReport, ReleaseSummary,
|
||||
};
|
||||
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
|
||||
pub use translation_memory::{
|
||||
translation_memory_context, translation_memory_repository_path,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -78,6 +78,10 @@ pub enum ReleaseFlowStatusCode {
|
||||
/// A localized release is published and matches the current official release.
|
||||
#[serde(rename = "localized.published")]
|
||||
LocalizedPublished,
|
||||
/// A localized current release exists but its manifest or published bytes
|
||||
/// fail read-only integrity verification.
|
||||
#[serde(rename = "localized.degraded")]
|
||||
LocalizedDegraded,
|
||||
/// Distribution cannot serve a usable release for the observed channel.
|
||||
#[serde(rename = "distribution.blocked")]
|
||||
DistributionBlocked,
|
||||
@@ -113,6 +117,7 @@ impl ReleaseFlowStatusCode {
|
||||
Self::LocalizedPending => "localized.pending",
|
||||
Self::LocalizedStale => "localized.stale",
|
||||
Self::LocalizedPublished => "localized.published",
|
||||
Self::LocalizedDegraded => "localized.degraded",
|
||||
Self::DistributionBlocked => "distribution.blocked",
|
||||
Self::DistributionReady => "distribution.ready",
|
||||
}
|
||||
@@ -146,6 +151,7 @@ impl ReleaseFlowStatusCode {
|
||||
Self::LocalizedPending => "pending",
|
||||
Self::LocalizedStale => "stale",
|
||||
Self::LocalizedPublished => "published",
|
||||
Self::LocalizedDegraded => "degraded",
|
||||
Self::DistributionBlocked => "blocked",
|
||||
Self::DistributionReady => "ready",
|
||||
}
|
||||
@@ -177,6 +183,7 @@ impl ReleaseFlowStatusCode {
|
||||
"localized.pending" => Self::LocalizedPending,
|
||||
"localized.stale" => Self::LocalizedStale,
|
||||
"localized.published" => Self::LocalizedPublished,
|
||||
"localized.degraded" => Self::LocalizedDegraded,
|
||||
"distribution.blocked" => Self::DistributionBlocked,
|
||||
"distribution.ready" => Self::DistributionReady,
|
||||
_ => return None,
|
||||
@@ -208,7 +215,8 @@ impl ReleaseFlowStatusCode {
|
||||
Self::LocalizedBlockedOfficial
|
||||
| Self::LocalizedPending
|
||||
| Self::LocalizedStale
|
||||
| Self::LocalizedPublished => "localized_publish",
|
||||
| Self::LocalizedPublished
|
||||
| Self::LocalizedDegraded => "localized_publish",
|
||||
Self::DistributionBlocked | Self::DistributionReady => "distribution",
|
||||
}
|
||||
}
|
||||
@@ -300,6 +308,7 @@ mod tests {
|
||||
ReleaseFlowStatusCode::LocalizedPending,
|
||||
ReleaseFlowStatusCode::LocalizedStale,
|
||||
ReleaseFlowStatusCode::LocalizedPublished,
|
||||
ReleaseFlowStatusCode::LocalizedDegraded,
|
||||
ReleaseFlowStatusCode::DistributionBlocked,
|
||||
ReleaseFlowStatusCode::DistributionReady,
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user