mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
fix(release):完善当前分发证明与质量门禁
This commit is contained in:
@@ -7,17 +7,19 @@ 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, 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,
|
||||
apply_unityfs_text_asset_patch_file, build_official_distribution_attestation,
|
||||
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,
|
||||
official_distribution_mapping_identity, 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,
|
||||
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,
|
||||
validate_translation_workbench_with_glossary_path,
|
||||
verify_and_record_official_distribution_attestation, write_file_atomic,
|
||||
write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig,
|
||||
LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
|
||||
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
|
||||
@@ -1155,6 +1157,25 @@ struct LocalizedRollbackRpcParams {
|
||||
localized_release_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ResourceManifestRpcParams {
|
||||
#[serde(default)]
|
||||
release_id: Option<String>,
|
||||
#[serde(default)]
|
||||
expected_publication_identity: Option<String>,
|
||||
#[serde(default)]
|
||||
expected_manifest_identity: Option<String>,
|
||||
#[serde(default)]
|
||||
offset: usize,
|
||||
#[serde(default = "default_rpc_manifest_limit")]
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
fn default_rpc_manifest_limit() -> usize {
|
||||
100
|
||||
}
|
||||
|
||||
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。
|
||||
const RPC_METHOD_STATUS: &str = "daemon.status";
|
||||
const RPC_METHOD_STOP: &str = "daemon.stop";
|
||||
@@ -1199,6 +1220,7 @@ 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_ATTESTATION: &str = "release.attestation";
|
||||
const RPC_METHOD_RELEASE_LIST: &str = "release.list";
|
||||
const RPC_METHOD_RELEASE_DISTRIBUTION: &str = "release.distribution";
|
||||
const RPC_METHOD_RELEASE_CLEANUP: &str = "release.cleanup";
|
||||
@@ -1982,6 +2004,18 @@ fn dispatch_rpc_method(
|
||||
"resource.state",
|
||||
build_resource_state_report(state_dir),
|
||||
),
|
||||
RPC_METHOD_RELEASE_ATTESTATION => {
|
||||
let _sync_guard = tasks
|
||||
.sync_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
rpc_envelope_from_result(
|
||||
request_id,
|
||||
RPC_METHOD_RELEASE_ATTESTATION,
|
||||
build_official_distribution_attestation(&tasks.base_config.output_root)
|
||||
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
|
||||
)
|
||||
}
|
||||
RPC_METHOD_SCHEDULE_LIST => {
|
||||
let params = request
|
||||
.params
|
||||
@@ -2084,23 +2118,27 @@ fn dispatch_rpc_method(
|
||||
enqueue_task_envelope(tasks, TaskKind::Repair, false, request_id)
|
||||
}
|
||||
RPC_METHOD_RESOURCE_MANIFEST => {
|
||||
let (offset, limit) = match rpc_page_params(request.params.as_ref()) {
|
||||
Ok(page) => page,
|
||||
Err(error) => {
|
||||
return rpc_envelope_error(
|
||||
request_id,
|
||||
ApiError::new(
|
||||
ErrorCode::RPC_INVALID_PARAMS,
|
||||
"resource.manifest",
|
||||
error.to_string(),
|
||||
),
|
||||
)
|
||||
}
|
||||
let params = match rpc_optional_struct_params::<ResourceManifestRpcParams>(
|
||||
request.params.as_ref(),
|
||||
RPC_METHOD_RESOURCE_MANIFEST,
|
||||
) {
|
||||
Ok(params) => params,
|
||||
Err(error) => return rpc_envelope_error(request_id, error),
|
||||
};
|
||||
if params.limit == 0 || params.limit > 1000 {
|
||||
return rpc_envelope_error(
|
||||
request_id,
|
||||
ApiError::new(
|
||||
ErrorCode::RPC_INVALID_PARAMS,
|
||||
RPC_METHOD_RESOURCE_MANIFEST,
|
||||
"limit 必须在 1..=1000 范围内",
|
||||
),
|
||||
);
|
||||
};
|
||||
rpc_envelope_from_result(
|
||||
request_id,
|
||||
"resource.manifest",
|
||||
build_resource_manifest_report(state_dir, offset, limit),
|
||||
RPC_METHOD_RESOURCE_MANIFEST,
|
||||
build_resource_manifest_report(state_dir, params),
|
||||
)
|
||||
}
|
||||
RPC_METHOD_RESOURCE_INDEX => {
|
||||
@@ -2969,39 +3007,102 @@ fn build_catalog_diff_report(state_dir: &Path) -> anyhow::Result<serde_json::Val
|
||||
/// `resource.manifest`:当前版本下载 manifest 的分页查询。
|
||||
fn build_resource_manifest_report(
|
||||
state_dir: &Path,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
params: ResourceManifestRpcParams,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let (_, version_state) = read_daemon_resource_state(state_dir)?;
|
||||
let (status_file, version_state) = read_daemon_resource_state(state_dir)?;
|
||||
let current = version_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.current_completed_version.as_ref());
|
||||
let Some(record) = current else {
|
||||
return Ok(serde_json::json!({ "available": false }));
|
||||
};
|
||||
if params
|
||||
.release_id
|
||||
.as_deref()
|
||||
.is_some_and(|release_id| release_id != record.id)
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"resource.manifest release_id 与当前 release 不一致:expected={:?} current={}",
|
||||
params.release_id,
|
||||
record.id
|
||||
));
|
||||
}
|
||||
let manifest_path = record.resource_root.join("official-download-manifest.json");
|
||||
let Some(manifest_bytes) =
|
||||
read_file_no_symlink(&manifest_path, "官方下载 manifest").map_err(anyhow::Error::msg)?
|
||||
else {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"release_id": record.id,
|
||||
"resource_root": record.resource_root,
|
||||
}));
|
||||
};
|
||||
let manifest = read_download_manifest_at(&record.resource_root).map_err(anyhow::Error::msg)?;
|
||||
let Some(manifest) = manifest else {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"release_id": record.id,
|
||||
"resource_root": record.resource_root,
|
||||
}));
|
||||
};
|
||||
let manifest_identity = blake3::hash(&manifest_bytes).to_hex().to_string();
|
||||
let mapping_identity = official_distribution_mapping_identity(&manifest);
|
||||
let attestation = status_file.as_ref().and_then(|status| {
|
||||
build_official_distribution_attestation(&status.resource_output_root).ok()
|
||||
});
|
||||
let publication_identity = attestation
|
||||
.as_ref()
|
||||
.map(|report| report.publication_identity.clone())
|
||||
.filter(|identity| !identity.is_empty())
|
||||
.unwrap_or_else(|| format!("manifest-v1-{manifest_identity}"));
|
||||
let verification_generation = attestation
|
||||
.as_ref()
|
||||
.map(|report| report.verification_generation)
|
||||
.unwrap_or(0);
|
||||
if params
|
||||
.expected_manifest_identity
|
||||
.as_deref()
|
||||
.is_some_and(|expected| expected != manifest_identity)
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"resource.manifest manifest_identity 不一致:expected={:?} actual={}",
|
||||
params.expected_manifest_identity,
|
||||
manifest_identity
|
||||
));
|
||||
}
|
||||
if params
|
||||
.expected_publication_identity
|
||||
.as_deref()
|
||||
.is_some_and(|expected| expected != publication_identity)
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"resource.manifest publication_identity 不一致:expected={:?} actual={}",
|
||||
params.expected_publication_identity,
|
||||
publication_identity
|
||||
));
|
||||
}
|
||||
let total_entries = manifest.entries.len();
|
||||
// BTreeMap 按 URL 有序迭代,分页结果稳定。
|
||||
let entries: Vec<_> = manifest
|
||||
.entries
|
||||
.values()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.skip(params.offset)
|
||||
.take(params.limit)
|
||||
.cloned()
|
||||
.collect();
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"channel": "official",
|
||||
"release_id": record.id,
|
||||
"resource_root": record.resource_root,
|
||||
"manifest_version": manifest.version,
|
||||
"publication_identity": publication_identity,
|
||||
"mapping_identity": mapping_identity,
|
||||
"manifest_identity": manifest_identity,
|
||||
"generation": verification_generation,
|
||||
"total_entries": total_entries,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"offset": params.offset,
|
||||
"limit": params.limit,
|
||||
"entries": entries,
|
||||
}))
|
||||
}
|
||||
@@ -5457,6 +5558,7 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
zip_error: item.zip_error.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
verify_and_record_official_distribution_attestation(&config)?;
|
||||
let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate
|
||||
&& update_report.local_manifest_repair_needed_count == 0
|
||||
&& verification.is_clean();
|
||||
|
||||
@@ -4453,6 +4453,9 @@ fn dispatch_resource_manifest_paginates() {
|
||||
assert_eq!(value["data"]["available"], true);
|
||||
assert_eq!(value["data"]["total_entries"], 3);
|
||||
assert_eq!(value["data"]["offset"], 1);
|
||||
assert_eq!(value["data"]["release_id"], "v-current");
|
||||
assert!(value["data"]["manifest_identity"].as_str().is_some());
|
||||
assert!(value["data"]["generation"].as_u64().is_some());
|
||||
let entries = value["data"]["entries"].as_array().unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0]["destination"], "b");
|
||||
@@ -4486,6 +4489,24 @@ fn dispatch_resource_manifest_paginates() {
|
||||
let entries = value["data"]["entries"].as_array().unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0]["destination"], "c");
|
||||
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request(
|
||||
"resource.manifest",
|
||||
Some(serde_json::json!({
|
||||
"release_id": "v-current",
|
||||
"expected_manifest_identity": "wrong-generation",
|
||||
"offset": 0,
|
||||
"limit": 1,
|
||||
})),
|
||||
),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&test_task_context(),
|
||||
"req-man-4".to_string(),
|
||||
);
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], false);
|
||||
}
|
||||
|
||||
fn write_resource_index_fixture(repository_path: &Path) {
|
||||
|
||||
@@ -603,6 +603,14 @@ pub(super) fn run_task_worker(
|
||||
)
|
||||
};
|
||||
run_result
|
||||
.and_then(|report| {
|
||||
if job.kind == TaskKind::Verify {
|
||||
bat_infrastructure::verify_and_record_official_distribution_attestation(
|
||||
&job.config,
|
||||
)?;
|
||||
}
|
||||
Ok(report)
|
||||
})
|
||||
.map(|report| serde_json::to_value(&report).map_err(anyhow::Error::from))
|
||||
.and_then(|result| result)
|
||||
};
|
||||
|
||||
+18
-15
@@ -82,13 +82,15 @@ pub use official_changes::{
|
||||
pub use official_download::{
|
||||
official_distribution_mapping_identity, read_cas_reuse_reference_manifest_at,
|
||||
read_download_manifest_at, release_cas_reuse_references, DownloadError,
|
||||
OfficialCasReuseReferenceManifest, OfficialDownloadManifest, OfficialDownloadManifestEntry,
|
||||
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
|
||||
OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport,
|
||||
OfficialResourceHashAlgorithm, OfficialResourceHashVerification, OfficialResourcePullItem,
|
||||
OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport,
|
||||
OfficialResourcePullService, OfficialResourcePullStatus, OfficialResourceReuseWarning,
|
||||
OfficialResourceVerification, OFFICIAL_CAS_REUSE_REFERENCES_FILE,
|
||||
OfficialCasReuseReferenceManifest, OfficialDistributionAttestation, OfficialDownloadManifest,
|
||||
OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem,
|
||||
OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus,
|
||||
OfficialLocalVerificationReport, OfficialResourceHashAlgorithm,
|
||||
OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress,
|
||||
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
|
||||
OfficialResourcePullStatus, OfficialResourceReuseWarning, OfficialResourceVerification,
|
||||
OFFICIAL_CAS_REUSE_REFERENCES_FILE, OFFICIAL_DISTRIBUTION_ATTESTATION_FILE,
|
||||
OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS, OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION,
|
||||
OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||
};
|
||||
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
||||
@@ -130,13 +132,13 @@ pub use official_textunit_queue::{
|
||||
pub use official_update::{
|
||||
cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging,
|
||||
gc_orphan_staging_with_cas_root, read_bootstrap_cache, read_snapshot, read_version_state,
|
||||
write_bootstrap_cache, write_snapshot, write_version_state, ExtendedSnapshotDelta,
|
||||
GameMainConfigSnapshot, LauncherMetadataSnapshot, LocalizedReleaseStatus,
|
||||
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot,
|
||||
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
|
||||
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
|
||||
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState,
|
||||
ResolvedBootstrap,
|
||||
verify_and_record_official_distribution_attestation, write_bootstrap_cache, write_snapshot,
|
||||
write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
|
||||
LocalizedReleaseStatus, OfficialBootstrapCache, OfficialEndpointMarkerRole,
|
||||
OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource,
|
||||
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
|
||||
OfficialVersionRecord, OfficialVersionState, ResolvedBootstrap,
|
||||
};
|
||||
pub use patch_ops::{
|
||||
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
|
||||
@@ -151,7 +153,8 @@ pub use path_security::{
|
||||
};
|
||||
pub use release_flow::ReleaseFlowStatusCode;
|
||||
pub use release_ops::{
|
||||
build_release_list, build_release_status, cleanup_releases, select_release_distribution,
|
||||
build_official_distribution_attestation, build_release_list, build_release_status,
|
||||
cleanup_releases, select_release_distribution, OfficialDistributionAttestationReport,
|
||||
ReleaseCleanupParams, ReleaseCleanupReport, ReleaseDistributionEntry, ReleaseDistributionPage,
|
||||
ReleaseDistributionParams, ReleaseListParams, ReleaseStatusReport, ReleaseSummary,
|
||||
};
|
||||
|
||||
@@ -71,6 +71,12 @@ const DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
|
||||
const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
|
||||
/// Independent publication fact for the official distribution manifest.
|
||||
pub const OFFICIAL_DISTRIBUTION_PUBLICATION_FILE: &str = "official-distribution-publication.json";
|
||||
/// Current official distribution verification result.
|
||||
pub const OFFICIAL_DISTRIBUTION_ATTESTATION_FILE: &str = "official-distribution-attestation.json";
|
||||
/// Persisted attestation schema version.
|
||||
pub const OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION: u32 = 1;
|
||||
/// Attestations older than this are no longer allowed to authorize current CDN.
|
||||
pub const OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS: u64 = 900;
|
||||
/// 记录一个已发布官方 release 获取的 CAS 引用。
|
||||
pub const OFFICIAL_CAS_REUSE_REFERENCES_FILE: &str = "official-cas-reuse-references.json";
|
||||
const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1;
|
||||
@@ -566,6 +572,154 @@ pub(crate) struct OfficialDistributionPublicationAnchor {
|
||||
|
||||
const OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION: u32 = 1;
|
||||
|
||||
/// Rust-owned lightweight proof that the current official publication is safe
|
||||
/// for the read-only distribution path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OfficialDistributionAttestation {
|
||||
/// Attestation schema version.
|
||||
pub version: u32,
|
||||
/// Distribution channel; currently always `official`.
|
||||
pub channel: String,
|
||||
/// Stable official release ID.
|
||||
pub official_release_id: String,
|
||||
/// Published version root this result describes.
|
||||
pub resource_root: PathBuf,
|
||||
/// Identity of the publication anchor and its manifest generation.
|
||||
pub publication_identity: String,
|
||||
/// Identity of the complete destination mapping.
|
||||
pub mapping_identity: String,
|
||||
/// BLAKE3 identity of the manifest bytes.
|
||||
pub manifest_identity: String,
|
||||
/// Number of entries in the bound manifest.
|
||||
pub entry_count: u64,
|
||||
/// `verified`, `stale`, `invalid`, or `unavailable`.
|
||||
pub integrity_status: String,
|
||||
/// Human-readable stable state label.
|
||||
pub status: String,
|
||||
/// Namespaced status code consumed by RPC clients.
|
||||
pub status_code: String,
|
||||
/// Whether this attestation currently authorizes distribution.
|
||||
pub ready: bool,
|
||||
/// Monotonic verification generation for this published root.
|
||||
pub verification_generation: u64,
|
||||
/// Time of the last successful full local verification.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub verified_at: Option<u64>,
|
||||
/// Freshness window used by the lightweight RPC reader.
|
||||
pub max_age_seconds: u64,
|
||||
/// Diagnostics retained with the result.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub diagnostics: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn official_distribution_publication_identity(
|
||||
anchor: &OfficialDistributionPublicationAnchor,
|
||||
) -> String {
|
||||
format!(
|
||||
"odp-v1-{}-{}",
|
||||
anchor.mapping_identity, anchor.manifest_identity
|
||||
)
|
||||
}
|
||||
|
||||
fn official_distribution_attestation_status_code(integrity_status: &str) -> &'static str {
|
||||
match integrity_status {
|
||||
"verified" => "distribution.ready",
|
||||
"stale" => "distribution.attestation_stale",
|
||||
"invalid" => "distribution.attestation_invalid",
|
||||
_ => "distribution.attestation_unavailable",
|
||||
}
|
||||
}
|
||||
|
||||
fn official_distribution_attestation_status(integrity_status: &str) -> &'static str {
|
||||
match integrity_status {
|
||||
"verified" => "ready",
|
||||
"stale" => "stale",
|
||||
"invalid" => "invalid",
|
||||
_ => "unavailable",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_official_distribution_attestation_at(
|
||||
release_root: &Path,
|
||||
) -> Result<Option<OfficialDistributionAttestation>, String> {
|
||||
let path = release_root.join(OFFICIAL_DISTRIBUTION_ATTESTATION_FILE);
|
||||
let Some(bytes) = read_file_no_symlink(&path, "官方 distribution attestation")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let attestation: OfficialDistributionAttestation = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("解析官方 distribution attestation 失败:{error}"))?;
|
||||
if attestation.version != OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION {
|
||||
return Err(format!(
|
||||
"不支持的官方 distribution attestation 版本:{}",
|
||||
attestation.version
|
||||
));
|
||||
}
|
||||
Ok(Some(attestation))
|
||||
}
|
||||
|
||||
/// Records a verification result for one already published official root.
|
||||
///
|
||||
/// The publication anchor is reused as the immutable generation identity.
|
||||
/// The caller chooses `verified` only after the existing full local audit has
|
||||
/// passed; this function itself never turns a partial audit into a healthy
|
||||
/// result.
|
||||
pub(crate) fn write_official_distribution_attestation_at(
|
||||
release_root: &Path,
|
||||
official_release_id: &str,
|
||||
integrity_status: &str,
|
||||
diagnostics: Vec<String>,
|
||||
) -> Result<OfficialDistributionAttestation, String> {
|
||||
if !matches!(
|
||||
integrity_status,
|
||||
"verified" | "stale" | "invalid" | "unavailable"
|
||||
) {
|
||||
return Err(format!(
|
||||
"不支持的官方 distribution attestation 状态:{integrity_status}"
|
||||
));
|
||||
}
|
||||
ensure_safe_directory_path(release_root, "官方 distribution attestation 根目录")?;
|
||||
let anchor = verify_official_distribution_publication_at(release_root, official_release_id)?
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"官方 distribution attestation 缺少 publication anchor:{}",
|
||||
release_root.display()
|
||||
)
|
||||
})?;
|
||||
let previous_generation = read_official_distribution_attestation_at(release_root)?
|
||||
.map(|previous| previous.verification_generation)
|
||||
.unwrap_or(0);
|
||||
let verified_at = (integrity_status == "verified").then_some(unix_seconds_now());
|
||||
let attestation = OfficialDistributionAttestation {
|
||||
version: OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION,
|
||||
channel: "official".to_string(),
|
||||
official_release_id: official_release_id.to_string(),
|
||||
resource_root: release_root.to_path_buf(),
|
||||
publication_identity: official_distribution_publication_identity(&anchor),
|
||||
mapping_identity: anchor.mapping_identity,
|
||||
manifest_identity: anchor.manifest_identity,
|
||||
entry_count: anchor.entry_count,
|
||||
integrity_status: integrity_status.to_string(),
|
||||
status: official_distribution_attestation_status(integrity_status).to_string(),
|
||||
status_code: official_distribution_attestation_status_code(integrity_status).to_string(),
|
||||
ready: integrity_status == "verified",
|
||||
verification_generation: previous_generation.saturating_add(1),
|
||||
verified_at,
|
||||
max_age_seconds: OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS,
|
||||
diagnostics,
|
||||
};
|
||||
let path = release_root.join(OFFICIAL_DISTRIBUTION_ATTESTATION_FILE);
|
||||
ensure_safe_file_target(release_root, &path, "官方 distribution attestation")?;
|
||||
let bytes = serde_json::to_vec_pretty(&attestation)
|
||||
.map_err(|error| format!("序列化官方 distribution attestation 失败:{error}"))?;
|
||||
write_file_atomic(
|
||||
&path,
|
||||
&bytes,
|
||||
STATE_FILE_MODE,
|
||||
"官方 distribution attestation",
|
||||
)?;
|
||||
Ok(attestation)
|
||||
}
|
||||
|
||||
/// Writes the independent publication anchor after the complete official
|
||||
/// release verification has succeeded.
|
||||
///
|
||||
|
||||
@@ -14,7 +14,8 @@ use crate::official_changes::{
|
||||
OfficialResourceChangeSummary,
|
||||
};
|
||||
use crate::official_download::{
|
||||
write_official_distribution_publication_anchor_at, OFFICIAL_CAS_REUSE_REFERENCES_FILE,
|
||||
write_official_distribution_attestation_at, write_official_distribution_publication_anchor_at,
|
||||
OfficialDistributionAttestation, OFFICIAL_CAS_REUSE_REFERENCES_FILE,
|
||||
OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||
};
|
||||
use crate::official_game_main_config::{
|
||||
@@ -1785,6 +1786,28 @@ impl OfficialUpdateService {
|
||||
&active_resource_root,
|
||||
&snapshot_path,
|
||||
)?;
|
||||
if let Some(audit) = local_audit.as_ref() {
|
||||
let integrity_status = if audit.is_clean() {
|
||||
"verified"
|
||||
} else {
|
||||
"invalid"
|
||||
};
|
||||
let diagnostics = if audit.is_clean() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![format!(
|
||||
"本地 manifest 审计失败:{} 项需要修复",
|
||||
audit.repair_needed_count()
|
||||
)]
|
||||
};
|
||||
write_official_distribution_attestation_at(
|
||||
&active_resource_root,
|
||||
&active_release_id,
|
||||
integrity_status,
|
||||
diagnostics,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
}
|
||||
let active_launcher_bootstrap_path =
|
||||
active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE);
|
||||
if bootstrap.is_some()
|
||||
@@ -2015,6 +2038,13 @@ impl OfficialUpdateService {
|
||||
&publish_plan.id,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
write_official_distribution_attestation_at(
|
||||
&publish_plan.staging_path,
|
||||
&publish_plan.id,
|
||||
"verified",
|
||||
Vec::new(),
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"snapshot",
|
||||
format!("写入快照 {}", staging_snapshot_path.display()),
|
||||
@@ -2180,6 +2210,58 @@ impl OfficialUpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the explicit full local verification and records its result for the
|
||||
/// lightweight current-distribution health RPC.
|
||||
///
|
||||
/// This is intentionally called only by the explicit verify task/command. The
|
||||
/// high-frequency health path reads the resulting attestation and never hashes
|
||||
/// resource artifacts.
|
||||
pub fn verify_and_record_official_distribution_attestation(
|
||||
config: &OfficialUpdateConfig,
|
||||
) -> anyhow::Result<OfficialDistributionAttestation> {
|
||||
let version_state = read_version_state(&config.version_state_path())?
|
||||
.ok_or_else(|| anyhow::anyhow!("官方版本状态不存在,无法记录 distribution attestation"))?;
|
||||
let record = version_state
|
||||
.current_completed_version
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("没有当前已发布官方 release,无法记录 distribution attestation")
|
||||
})?;
|
||||
let resource_root = OfficialPublishLayout::new(&config.output_root)
|
||||
.active_resource_root()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if resource_root != record.resource_root {
|
||||
return Err(anyhow::anyhow!(
|
||||
"current resource root 与版本状态不一致:current={} state={}",
|
||||
resource_root.display(),
|
||||
record.resource_root.display()
|
||||
));
|
||||
}
|
||||
let verification =
|
||||
OfficialResourcePullService::with_curl_command(&resource_root, &config.curl_command)
|
||||
.with_proxy_config(config.curl_proxy.clone())
|
||||
.verify_local_download_manifest()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let diagnostics = verification
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| !item.status.is_verified())
|
||||
.map(|item| format!("{}: {}", item.destination.display(), item.status.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
let integrity_status = if verification.is_clean() {
|
||||
"verified"
|
||||
} else {
|
||||
"invalid"
|
||||
};
|
||||
write_official_distribution_attestation_at(
|
||||
&resource_root,
|
||||
&record.id,
|
||||
integrity_status,
|
||||
diagnostics,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)
|
||||
}
|
||||
|
||||
fn run_post_sync_resource_handoff(
|
||||
previous_resource_root: Option<&Path>,
|
||||
current_resource_root: &Path,
|
||||
|
||||
@@ -11,9 +11,11 @@ use crate::localized_patch::{
|
||||
LOCALIZED_VERSIONS_DIR,
|
||||
};
|
||||
use crate::official_download::{
|
||||
read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references,
|
||||
verify_official_distribution_publication_at, OfficialDownloadManifest,
|
||||
OfficialDownloadManifestEntry,
|
||||
official_distribution_publication_identity, read_cas_reuse_reference_manifest_at,
|
||||
read_download_manifest_at, read_official_distribution_attestation_at,
|
||||
release_cas_reuse_references, verify_official_distribution_publication_at,
|
||||
OfficialDownloadManifest, OfficialDownloadManifestEntry,
|
||||
OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS,
|
||||
};
|
||||
use crate::official_update::{read_version_state, OfficialVersionRecord, OfficialVersionState};
|
||||
use crate::path_security::{
|
||||
@@ -24,6 +26,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const OFFICIAL_CURRENT_LINK: &str = "current";
|
||||
const OFFICIAL_STAGING_DIR: &str = ".staging";
|
||||
@@ -148,6 +151,47 @@ pub struct ReleaseStatusReport {
|
||||
pub releases: Vec<ReleaseSummary>,
|
||||
}
|
||||
|
||||
/// Lightweight current-official distribution health proof.
|
||||
///
|
||||
/// Unlike `ReleaseStatusReport`, this report never scans historical releases
|
||||
/// or hashes resource artifacts. It only evaluates the current pointer,
|
||||
/// publication anchor, persisted attestation, and attestation freshness.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct OfficialDistributionAttestationReport {
|
||||
/// Whether a current official version record exists.
|
||||
pub available: bool,
|
||||
/// Distribution channel.
|
||||
pub channel: String,
|
||||
/// Current official release ID.
|
||||
pub release_id: String,
|
||||
/// Current versioned resource root.
|
||||
pub resource_root: String,
|
||||
/// Publication/generation identity.
|
||||
pub publication_identity: String,
|
||||
/// Complete mapping identity.
|
||||
pub mapping_identity: String,
|
||||
/// Manifest byte identity.
|
||||
pub manifest_identity: String,
|
||||
/// Number of bound manifest entries.
|
||||
pub entry_count: u64,
|
||||
/// `verified`, `stale`, `invalid`, or `unavailable`.
|
||||
pub integrity_status: String,
|
||||
/// Stable state label.
|
||||
pub status: String,
|
||||
/// Stable namespaced status code.
|
||||
pub status_code: String,
|
||||
/// Whether current official distribution may be served.
|
||||
pub ready: bool,
|
||||
/// Verification generation from the persisted attestation.
|
||||
pub verification_generation: u64,
|
||||
/// Last successful full verification time.
|
||||
pub verified_at: Option<u64>,
|
||||
/// Freshness window used by the reader.
|
||||
pub max_age_seconds: u64,
|
||||
/// Diagnostics explaining a blocked result.
|
||||
pub diagnostics: Vec<String>,
|
||||
}
|
||||
|
||||
/// A page of resources from a Rust-verified release choice.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ReleaseDistributionPage {
|
||||
@@ -309,6 +353,196 @@ pub fn build_release_status(
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the lightweight attestation for the current official release.
|
||||
pub fn build_official_distribution_attestation(
|
||||
official_root: &Path,
|
||||
) -> anyhow::Result<OfficialDistributionAttestationReport> {
|
||||
let state = read_version_state(&official_root.join(OFFICIAL_VERSION_STATE_FILE))?;
|
||||
let Some(record) = state
|
||||
.as_ref()
|
||||
.and_then(|state| state.current_completed_version.as_ref())
|
||||
else {
|
||||
return Ok(OfficialDistributionAttestationReport {
|
||||
available: false,
|
||||
channel: "official".to_string(),
|
||||
release_id: String::new(),
|
||||
resource_root: String::new(),
|
||||
publication_identity: String::new(),
|
||||
mapping_identity: String::new(),
|
||||
manifest_identity: String::new(),
|
||||
entry_count: 0,
|
||||
integrity_status: "unavailable".to_string(),
|
||||
status: "unavailable".to_string(),
|
||||
status_code: "distribution.attestation_unavailable".to_string(),
|
||||
ready: false,
|
||||
verification_generation: 0,
|
||||
verified_at: None,
|
||||
max_age_seconds: OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS,
|
||||
diagnostics: vec!["没有当前已发布官方 release".to_string()],
|
||||
});
|
||||
};
|
||||
|
||||
let mut report = OfficialDistributionAttestationReport {
|
||||
available: true,
|
||||
channel: "official".to_string(),
|
||||
release_id: record.id.clone(),
|
||||
resource_root: record.resource_root.display().to_string(),
|
||||
publication_identity: String::new(),
|
||||
mapping_identity: String::new(),
|
||||
manifest_identity: String::new(),
|
||||
entry_count: 0,
|
||||
integrity_status: "unavailable".to_string(),
|
||||
status: "unavailable".to_string(),
|
||||
status_code: "distribution.attestation_unavailable".to_string(),
|
||||
ready: false,
|
||||
verification_generation: 0,
|
||||
verified_at: None,
|
||||
max_age_seconds: OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS,
|
||||
diagnostics: Vec::new(),
|
||||
};
|
||||
|
||||
let current_id = read_managed_current_id(
|
||||
&official_root.join(OFFICIAL_CURRENT_LINK),
|
||||
OFFICIAL_VERSIONS_DIR,
|
||||
);
|
||||
if current_id.as_deref() != Some(record.id.as_str()) {
|
||||
report.integrity_status = "invalid".to_string();
|
||||
report.status = "invalid".to_string();
|
||||
report.status_code = "distribution.attestation_invalid".to_string();
|
||||
report.diagnostics.push(format!(
|
||||
"current 指针与版本状态不一致:pointer={:?} state={}",
|
||||
current_id, record.id
|
||||
));
|
||||
}
|
||||
if let Err(error) = ensure_path_within_root(official_root, &record.resource_root) {
|
||||
report.integrity_status = "invalid".to_string();
|
||||
report.status = "invalid".to_string();
|
||||
report.status_code = "distribution.attestation_invalid".to_string();
|
||||
report.diagnostics.push(error);
|
||||
}
|
||||
if let Err(error) =
|
||||
ensure_safe_directory_path(&record.resource_root, "当前官方 distribution 根目录")
|
||||
{
|
||||
report.integrity_status = "invalid".to_string();
|
||||
report.status = "invalid".to_string();
|
||||
report.status_code = "distribution.attestation_invalid".to_string();
|
||||
report.diagnostics.push(error);
|
||||
}
|
||||
|
||||
let anchor =
|
||||
match verify_official_distribution_publication_at(&record.resource_root, &record.id) {
|
||||
Ok(Some(anchor)) => {
|
||||
report.publication_identity = official_distribution_publication_identity(&anchor);
|
||||
report.mapping_identity = anchor.mapping_identity.clone();
|
||||
report.manifest_identity = anchor.manifest_identity.clone();
|
||||
report.entry_count = anchor.entry_count;
|
||||
Some(anchor)
|
||||
}
|
||||
Ok(None) => {
|
||||
report
|
||||
.diagnostics
|
||||
.push("缺少 publication anchor".to_string());
|
||||
None
|
||||
}
|
||||
Err(error) => {
|
||||
report.diagnostics.push(error);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let attestation = match read_official_distribution_attestation_at(&record.resource_root) {
|
||||
Ok(attestation) => attestation,
|
||||
Err(error) => {
|
||||
report.diagnostics.push(error);
|
||||
None
|
||||
}
|
||||
};
|
||||
let Some(attestation) = attestation else {
|
||||
if report.integrity_status != "invalid" {
|
||||
report.integrity_status = "unavailable".to_string();
|
||||
report.status = "unavailable".to_string();
|
||||
report.status_code = "distribution.attestation_unavailable".to_string();
|
||||
}
|
||||
report
|
||||
.diagnostics
|
||||
.push("缺少当前 release attestation".to_string());
|
||||
return Ok(report);
|
||||
};
|
||||
|
||||
report.integrity_status = attestation.integrity_status.clone();
|
||||
report.status = attestation.status.clone();
|
||||
report.status_code = attestation.status_code.clone();
|
||||
report.publication_identity = attestation.publication_identity.clone();
|
||||
report.mapping_identity = attestation.mapping_identity.clone();
|
||||
report.manifest_identity = attestation.manifest_identity.clone();
|
||||
report.entry_count = attestation.entry_count;
|
||||
report.verification_generation = attestation.verification_generation;
|
||||
report.verified_at = attestation.verified_at;
|
||||
report.max_age_seconds = if attestation.max_age_seconds == 0 {
|
||||
OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS
|
||||
} else {
|
||||
attestation.max_age_seconds
|
||||
};
|
||||
report.diagnostics.extend(attestation.diagnostics);
|
||||
|
||||
let identity_matches = anchor.as_ref().is_some_and(|anchor| {
|
||||
attestation.channel == "official"
|
||||
&& attestation.official_release_id == record.id
|
||||
&& attestation.resource_root == record.resource_root
|
||||
&& attestation.publication_identity
|
||||
== official_distribution_publication_identity(anchor)
|
||||
&& attestation.mapping_identity == anchor.mapping_identity
|
||||
&& attestation.manifest_identity == anchor.manifest_identity
|
||||
&& attestation.entry_count == anchor.entry_count
|
||||
});
|
||||
if !identity_matches {
|
||||
report.integrity_status = "invalid".to_string();
|
||||
report.status = "invalid".to_string();
|
||||
report.status_code = "distribution.attestation_invalid".to_string();
|
||||
report.ready = false;
|
||||
report
|
||||
.diagnostics
|
||||
.push("attestation 与当前 publication generation 不一致".to_string());
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
if report.integrity_status == "verified" {
|
||||
let fresh = report.verified_at.is_some_and(|verified_at| {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default();
|
||||
now.saturating_sub(verified_at) <= report.max_age_seconds
|
||||
});
|
||||
if !fresh {
|
||||
report.integrity_status = "stale".to_string();
|
||||
report.status = "stale".to_string();
|
||||
report.status_code = "distribution.attestation_stale".to_string();
|
||||
report.ready = false;
|
||||
report
|
||||
.diagnostics
|
||||
.push("当前官方 attestation 已超过 freshness window".to_string());
|
||||
return Ok(report);
|
||||
}
|
||||
if !attestation.ready {
|
||||
report.integrity_status = "invalid".to_string();
|
||||
report.status = "invalid".to_string();
|
||||
report.status_code = "distribution.attestation_invalid".to_string();
|
||||
report.ready = false;
|
||||
report
|
||||
.diagnostics
|
||||
.push("attestation integrity=verified 但 ready=false".to_string());
|
||||
return Ok(report);
|
||||
}
|
||||
report.status = "ready".to_string();
|
||||
report.status_code = "distribution.ready".to_string();
|
||||
report.ready = true;
|
||||
} else {
|
||||
report.ready = false;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Lists one or both release namespaces.
|
||||
pub fn build_release_list(
|
||||
official_root: &Path,
|
||||
@@ -1793,6 +2027,77 @@ mod tests {
|
||||
assert!(!official_blocked.available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightweight_attestation_is_current_fresh_and_does_not_hash_artifacts() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let official_root = temp.path().join("official");
|
||||
let version = prepare_official(&official_root, "official-v1");
|
||||
fs::create_dir_all(&official_root).unwrap();
|
||||
symlink(
|
||||
Path::new(OFFICIAL_VERSIONS_DIR).join("official-v1"),
|
||||
official_root.join(OFFICIAL_CURRENT_LINK),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
official_root.join(OFFICIAL_VERSION_STATE_FILE),
|
||||
serde_json::to_vec(&OfficialVersionState {
|
||||
current_completed_version: Some(official_record(&official_root, "official-v1")),
|
||||
..OfficialVersionState::default()
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
crate::official_download::write_official_distribution_attestation_at(
|
||||
&version,
|
||||
"official-v1",
|
||||
"verified",
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let healthy = build_official_distribution_attestation(&official_root).unwrap();
|
||||
assert!(healthy.ready);
|
||||
assert_eq!(healthy.integrity_status, "verified");
|
||||
assert_eq!(healthy.release_id, "official-v1");
|
||||
assert!(healthy.verification_generation > 0);
|
||||
|
||||
// The lightweight read path only rechecks the publication anchor and
|
||||
// manifest bytes; artifact damage is recorded by explicit verify.
|
||||
fs::write(version.join("other.bin"), b"tampered").unwrap();
|
||||
let still_authorized = build_official_distribution_attestation(&official_root).unwrap();
|
||||
assert!(still_authorized.ready);
|
||||
|
||||
crate::official_download::write_official_distribution_attestation_at(
|
||||
&version,
|
||||
"official-v1",
|
||||
"invalid",
|
||||
vec!["other.bin: size_or_hash_mismatch".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
let invalid = build_official_distribution_attestation(&official_root).unwrap();
|
||||
assert!(!invalid.ready);
|
||||
assert_eq!(invalid.integrity_status, "invalid");
|
||||
|
||||
fs::write(version.join("other.bin"), b"official-other").unwrap();
|
||||
let mut fresh =
|
||||
crate::official_download::read_official_distribution_attestation_at(&version)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
fresh.integrity_status = "verified".to_string();
|
||||
fresh.status = "ready".to_string();
|
||||
fresh.status_code = "distribution.ready".to_string();
|
||||
fresh.ready = true;
|
||||
fresh.verified_at = Some(0);
|
||||
fs::write(
|
||||
version.join(crate::OFFICIAL_DISTRIBUTION_ATTESTATION_FILE),
|
||||
serde_json::to_vec(&fresh).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let stale = build_official_distribution_attestation(&official_root).unwrap();
|
||||
assert!(!stale.ready);
|
||||
assert_eq!(stale.integrity_status, "stale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribution_selection_does_not_fallback_from_damaged_localized_release() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user