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:
@@ -120,8 +120,9 @@ use workflow_commands::{
|
||||
|
||||
const EXIT_ERROR: i32 = 1;
|
||||
const EXIT_LOCKED: i32 = 75;
|
||||
const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60;
|
||||
const DEFAULT_ERROR_RETRY_SECONDS: u64 = 60;
|
||||
const DEFAULT_WATCH_INTERVAL_SECONDS: u64 =
|
||||
bat_infrastructure::DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS;
|
||||
const DEFAULT_ERROR_RETRY_SECONDS: u64 = bat_infrastructure::DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS;
|
||||
const DEFAULT_DAEMON_STATE_DIR: &str = "/tmp/bat-pid";
|
||||
const DAEMON_PID_FILE: &str = "bat.pid";
|
||||
const DAEMON_STATUS_FILE: &str = "bat-status.json";
|
||||
@@ -184,10 +185,13 @@ fn run() -> anyhow::Result<i32> {
|
||||
} else {
|
||||
assert_no_live_daemon_output_conflict(&options, "run")?;
|
||||
let mut logger = ProgressLogger::new(options.progress);
|
||||
let report =
|
||||
OfficialUpdateService::new().run_with_progress(&options.config, |event| {
|
||||
logger.log(event);
|
||||
})?;
|
||||
let report = OfficialUpdateService::with_verification_cadence(
|
||||
options.interval,
|
||||
options.error_retry_interval,
|
||||
)
|
||||
.run_with_progress(&options.config, |event| {
|
||||
logger.log(event);
|
||||
})?;
|
||||
if should_print_status(report.update_status, options.quiet_up_to_date) {
|
||||
report_output::print_report(options.output_format, &report)?;
|
||||
}
|
||||
@@ -717,7 +721,10 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
}
|
||||
install_shutdown_signal_handlers()?;
|
||||
clear_shutdown_signal_request();
|
||||
let service = OfficialUpdateService::new();
|
||||
let service = OfficialUpdateService::with_verification_cadence(
|
||||
options.interval,
|
||||
options.error_retry_interval,
|
||||
);
|
||||
let mut logger = ProgressLogger::new(options.progress);
|
||||
let daemon_state_dir = options.state_dir.clone();
|
||||
if options.daemon_child {
|
||||
@@ -747,9 +754,12 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
let registry = registry.clone();
|
||||
let sync_lock = Arc::clone(&sync_lock);
|
||||
let control = Arc::clone(control);
|
||||
let worker_service = service.clone();
|
||||
thread::Builder::new()
|
||||
.name("bat-daemon-task-worker".to_string())
|
||||
.spawn(move || run_task_worker(task_rx, registry, sync_lock, control))?
|
||||
.spawn(move || {
|
||||
run_task_worker(task_rx, registry, sync_lock, control, worker_service)
|
||||
})?
|
||||
};
|
||||
let context = DaemonTaskContext {
|
||||
registry,
|
||||
@@ -792,6 +802,9 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
let mut iteration_config = options.config.clone();
|
||||
// Watch 是 current release 的唯一周期性本地验证入口,不能被一次性
|
||||
// 同步命令的 audit_local 选项关闭。
|
||||
iteration_config.audit_local = true;
|
||||
if pending_scheduled_force {
|
||||
iteration_config.force = true;
|
||||
}
|
||||
@@ -1167,6 +1180,8 @@ struct ResourceManifestRpcParams {
|
||||
#[serde(default)]
|
||||
expected_manifest_identity: Option<String>,
|
||||
#[serde(default)]
|
||||
expected_verification_generation: Option<u64>,
|
||||
#[serde(default)]
|
||||
offset: usize,
|
||||
#[serde(default = "default_rpc_manifest_limit")]
|
||||
limit: usize,
|
||||
@@ -3027,6 +3042,15 @@ fn build_resource_manifest_report(
|
||||
record.id
|
||||
));
|
||||
}
|
||||
if params.release_id.is_none()
|
||||
|| params.expected_publication_identity.is_none()
|
||||
|| params.expected_manifest_identity.is_none()
|
||||
|| params.expected_verification_generation.is_none()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"resource.manifest 请求必须同时携带 release_id、publication_identity、manifest_identity 和 verification_generation"
|
||||
));
|
||||
}
|
||||
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)?
|
||||
@@ -3047,18 +3071,59 @@ fn build_resource_manifest_report(
|
||||
};
|
||||
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
|
||||
let attestation = status_file
|
||||
.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);
|
||||
.map(|status| {
|
||||
build_official_distribution_attestation(&status.resource_output_root).map_err(|error| {
|
||||
anyhow::anyhow!("读取 resource.manifest attestation 失败:{error}")
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
if let Some(report) = attestation.as_ref() {
|
||||
if report.resource_root != record.resource_root.display().to_string() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"resource.manifest attestation root 与当前 release 不一致:attestation={} current={}",
|
||||
report.resource_root,
|
||||
record.resource_root.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
let Some(report) = attestation.as_ref() else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"resource.manifest 请求缺少当前 Rust attestation"
|
||||
));
|
||||
};
|
||||
let publication_identity = report.publication_identity.clone();
|
||||
let verification_generation = report.verification_generation;
|
||||
if !report.available
|
||||
|| !report.ready
|
||||
|| report.integrity_status != "verified"
|
||||
|| report.channel != "official"
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"resource.manifest 当前 attestation 不可用:status={} integrity_status={}",
|
||||
report.status,
|
||||
report.integrity_status
|
||||
));
|
||||
}
|
||||
if report.release_id != record.id
|
||||
|| report.publication_identity != publication_identity
|
||||
|| report.mapping_identity != mapping_identity
|
||||
|| report.manifest_identity != manifest_identity
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"resource.manifest 当前 attestation identity 与 manifest 不一致"
|
||||
));
|
||||
}
|
||||
if params.release_id.as_deref() != Some(record.id.as_str())
|
||||
|| params.expected_publication_identity.as_deref() != Some(publication_identity.as_str())
|
||||
|| params.expected_manifest_identity.as_deref() != Some(manifest_identity.as_str())
|
||||
|| params.expected_verification_generation != Some(verification_generation)
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"resource.manifest 请求 identity 或 verification_generation 与当前 attestation 不一致"
|
||||
));
|
||||
}
|
||||
if params
|
||||
.expected_manifest_identity
|
||||
.as_deref()
|
||||
@@ -5378,8 +5443,11 @@ fn run_sync_command_foreground(
|
||||
config.force = false;
|
||||
}
|
||||
let mut logger = ProgressLogger::new(options.progress);
|
||||
let report =
|
||||
OfficialUpdateService::new().run_with_progress(&config, |event| logger.log(event))?;
|
||||
let report = OfficialUpdateService::with_verification_cadence(
|
||||
options.interval,
|
||||
options.error_retry_interval,
|
||||
)
|
||||
.run_with_progress(&config, |event| logger.log(event))?;
|
||||
let command_report = CommandReport {
|
||||
command: command_name,
|
||||
status: "completed",
|
||||
@@ -5533,8 +5601,22 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
config.force = false;
|
||||
|
||||
let mut logger = ProgressLogger::new(options.progress);
|
||||
let update_report =
|
||||
OfficialUpdateService::new().run_with_progress(&config, |event| logger.log(event))?;
|
||||
let service = OfficialUpdateService::with_verification_cadence(
|
||||
options.interval,
|
||||
options.error_retry_interval,
|
||||
);
|
||||
let update_report = match service.run_with_progress(&config, |event| logger.log(event)) {
|
||||
Ok(report) => report,
|
||||
Err(error) => {
|
||||
// A verifier error must revoke the previous ready generation
|
||||
// before the command returns its original failure.
|
||||
let _ = verify_and_record_official_distribution_attestation(
|
||||
&config,
|
||||
service.attestation_max_age_seconds(),
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let verified_resource_root = active_official_resource_root(&config.output_root)?;
|
||||
let verification = bat_infrastructure::OfficialResourcePullService::with_curl_command(
|
||||
&verified_resource_root,
|
||||
@@ -5558,7 +5640,10 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
zip_error: item.zip_error.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
verify_and_record_official_distribution_attestation(&config)?;
|
||||
verify_and_record_official_distribution_attestation(
|
||||
&config,
|
||||
service.attestation_max_age_seconds(),
|
||||
)?;
|
||||
let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate
|
||||
&& update_report.local_manifest_repair_needed_count == 0
|
||||
&& verification.is_clean();
|
||||
|
||||
@@ -4181,7 +4181,7 @@ fn dispatch_catalog_status_reads_current_snapshot() {
|
||||
"req-cat-1".to_string(),
|
||||
);
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], true);
|
||||
assert_eq!(value["ok"], true, "{value}");
|
||||
assert_eq!(value["data"]["available"], true);
|
||||
assert_eq!(value["data"]["bundle_version"], "bundle-b2");
|
||||
assert_eq!(value["data"]["status"], "published");
|
||||
@@ -4405,6 +4405,8 @@ fn catalog_refresh_config_is_dry_run_plan_only() {
|
||||
|
||||
#[test]
|
||||
fn dispatch_resource_manifest_paginates() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("output");
|
||||
@@ -4432,16 +4434,80 @@ fn dispatch_resource_manifest_paginates() {
|
||||
},
|
||||
},
|
||||
});
|
||||
let mut manifest: bat_infrastructure::OfficialDownloadManifest =
|
||||
serde_json::from_value(manifest).unwrap();
|
||||
let mapping_identity = bat_infrastructure::official_distribution_mapping_identity(&manifest);
|
||||
manifest.distribution_mapping_identity = Some(mapping_identity.clone());
|
||||
manifest.destination_index = manifest
|
||||
.entries
|
||||
.values()
|
||||
.map(|entry| (entry.destination.clone(), entry.url.clone()))
|
||||
.collect();
|
||||
let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
fs::write(
|
||||
current_dir.join("official-download-manifest.json"),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
&manifest_bytes,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest_identity = blake3::hash(&manifest_bytes).to_hex().to_string();
|
||||
let publication_identity = format!("odp-v1-{mapping_identity}-{manifest_identity}");
|
||||
fs::write(
|
||||
current_dir.join("official-distribution-publication.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"version": 1,
|
||||
"official_release_id": "v-current",
|
||||
"mapping_identity": mapping_identity,
|
||||
"manifest_identity": manifest_identity,
|
||||
"entry_count": 3,
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
current_dir.join("official-distribution-attestation.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"version": 1,
|
||||
"channel": "official",
|
||||
"official_release_id": "v-current",
|
||||
"resource_root": current_dir,
|
||||
"publication_identity": publication_identity,
|
||||
"mapping_identity": mapping_identity,
|
||||
"manifest_identity": manifest_identity,
|
||||
"entry_count": 3,
|
||||
"integrity_status": "verified",
|
||||
"status": "ready",
|
||||
"status_code": "distribution.ready",
|
||||
"ready": true,
|
||||
"verification_generation": 1,
|
||||
"verified_at": unix_seconds_now(),
|
||||
"max_age_seconds": 7260,
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
symlink(
|
||||
Path::new("versions").join("v-current"),
|
||||
output_root.join("current"),
|
||||
)
|
||||
.unwrap();
|
||||
let bound_params = serde_json::json!({
|
||||
"release_id": "v-current",
|
||||
"expected_publication_identity": publication_identity,
|
||||
"expected_manifest_identity": manifest_identity,
|
||||
"expected_verification_generation": 1,
|
||||
});
|
||||
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request(
|
||||
"resource.manifest",
|
||||
Some(serde_json::json!({ "offset": 1, "limit": 2 })),
|
||||
Some(serde_json::json!({
|
||||
"release_id": bound_params["release_id"],
|
||||
"expected_publication_identity": bound_params["expected_publication_identity"],
|
||||
"expected_manifest_identity": bound_params["expected_manifest_identity"],
|
||||
"expected_verification_generation": bound_params["expected_verification_generation"],
|
||||
"offset": 1,
|
||||
"limit": 2,
|
||||
})),
|
||||
),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
@@ -4463,7 +4529,16 @@ fn dispatch_resource_manifest_paginates() {
|
||||
|
||||
// 非法 limit → 参数错误。
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request("resource.manifest", Some(serde_json::json!({ "limit": 0 }))),
|
||||
&rpc_request(
|
||||
"resource.manifest",
|
||||
Some(serde_json::json!({
|
||||
"release_id": bound_params["release_id"],
|
||||
"expected_publication_identity": bound_params["expected_publication_identity"],
|
||||
"expected_manifest_identity": bound_params["expected_manifest_identity"],
|
||||
"expected_verification_generation": bound_params["expected_verification_generation"],
|
||||
"limit": 0,
|
||||
})),
|
||||
),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&test_task_context(),
|
||||
@@ -4476,7 +4551,14 @@ fn dispatch_resource_manifest_paginates() {
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request(
|
||||
"resource.list",
|
||||
Some(serde_json::json!({ "offset": 2, "limit": 1 })),
|
||||
Some(serde_json::json!({
|
||||
"release_id": bound_params["release_id"],
|
||||
"expected_publication_identity": bound_params["expected_publication_identity"],
|
||||
"expected_manifest_identity": bound_params["expected_manifest_identity"],
|
||||
"expected_verification_generation": bound_params["expected_verification_generation"],
|
||||
"offset": 2,
|
||||
"limit": 1,
|
||||
})),
|
||||
),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
@@ -4495,7 +4577,9 @@ fn dispatch_resource_manifest_paginates() {
|
||||
"resource.manifest",
|
||||
Some(serde_json::json!({
|
||||
"release_id": "v-current",
|
||||
"expected_publication_identity": publication_identity,
|
||||
"expected_manifest_identity": "wrong-generation",
|
||||
"expected_verification_generation": 1,
|
||||
"offset": 0,
|
||||
"limit": 1,
|
||||
})),
|
||||
@@ -4509,6 +4593,131 @@ fn dispatch_resource_manifest_paginates() {
|
||||
assert_eq!(value["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_resource_manifest_rejects_previous_verification_generation() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("output");
|
||||
let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None);
|
||||
let manifest = serde_json::json!({
|
||||
"version": 1,
|
||||
"entries": {
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/a": {
|
||||
"url": "https://prod-clientpatch.bluearchiveyostar.com/a",
|
||||
"destination": "a",
|
||||
"bytes": 1,
|
||||
"blake3": blake3::hash(b"a").to_hex().to_string(),
|
||||
},
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/b": {
|
||||
"url": "https://prod-clientpatch.bluearchiveyostar.com/b",
|
||||
"destination": "b",
|
||||
"bytes": 1,
|
||||
"blake3": blake3::hash(b"b").to_hex().to_string(),
|
||||
},
|
||||
},
|
||||
});
|
||||
let mut manifest: bat_infrastructure::OfficialDownloadManifest =
|
||||
serde_json::from_value(manifest).unwrap();
|
||||
manifest.distribution_mapping_identity = Some(
|
||||
bat_infrastructure::official_distribution_mapping_identity(&manifest),
|
||||
);
|
||||
manifest.destination_index = manifest
|
||||
.entries
|
||||
.values()
|
||||
.map(|entry| (entry.destination.clone(), entry.url.clone()))
|
||||
.collect();
|
||||
let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
|
||||
fs::write(
|
||||
current_dir.join("official-download-manifest.json"),
|
||||
&manifest_bytes,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(current_dir.join("a"), b"a").unwrap();
|
||||
fs::write(current_dir.join("b"), b"b").unwrap();
|
||||
symlink(
|
||||
Path::new("versions").join("v-current"),
|
||||
output_root.join("current"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mapping_identity = bat_infrastructure::official_distribution_mapping_identity(&manifest);
|
||||
let manifest_identity = blake3::hash(&manifest_bytes).to_hex().to_string();
|
||||
let publication_identity = format!("odp-v1-{mapping_identity}-{manifest_identity}");
|
||||
let write_attestation = |generation: u64| {
|
||||
fs::write(
|
||||
current_dir.join("official-distribution-publication.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"version": 1,
|
||||
"official_release_id": "v-current",
|
||||
"mapping_identity": mapping_identity,
|
||||
"manifest_identity": manifest_identity,
|
||||
"entry_count": 2,
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
current_dir.join("official-distribution-attestation.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"version": 1,
|
||||
"channel": "official",
|
||||
"official_release_id": "v-current",
|
||||
"resource_root": current_dir,
|
||||
"publication_identity": publication_identity,
|
||||
"mapping_identity": mapping_identity,
|
||||
"manifest_identity": manifest_identity,
|
||||
"entry_count": 2,
|
||||
"integrity_status": "verified",
|
||||
"status": "ready",
|
||||
"status_code": "distribution.ready",
|
||||
"ready": true,
|
||||
"verification_generation": generation,
|
||||
"verified_at": unix_seconds_now(),
|
||||
"max_age_seconds": 7260,
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
write_attestation(4);
|
||||
|
||||
let bound_params = serde_json::json!({
|
||||
"release_id": "v-current",
|
||||
"expected_publication_identity": publication_identity,
|
||||
"expected_manifest_identity": manifest_identity,
|
||||
"expected_verification_generation": 4,
|
||||
"offset": 0,
|
||||
"limit": 1,
|
||||
});
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request("resource.manifest", Some(bound_params.clone())),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&test_task_context(),
|
||||
"req-generation-1".to_string(),
|
||||
);
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], true);
|
||||
assert_eq!(value["data"]["generation"], 4);
|
||||
assert_eq!(
|
||||
value["data"]["resource_root"],
|
||||
current_dir.to_string_lossy().as_ref()
|
||||
);
|
||||
|
||||
write_attestation(5);
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request("resource.manifest", Some(bound_params)),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&test_task_context(),
|
||||
"req-generation-2".to_string(),
|
||||
);
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], false);
|
||||
}
|
||||
|
||||
fn write_resource_index_fixture(repository_path: &Path) {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
|
||||
@@ -537,8 +537,8 @@ pub(super) fn run_task_worker(
|
||||
registry: TaskRegistry,
|
||||
sync_lock: Arc<Mutex<()>>,
|
||||
control: DaemonControl,
|
||||
service: OfficialUpdateService,
|
||||
) {
|
||||
let service = OfficialUpdateService::new();
|
||||
for job in receiver {
|
||||
registry.update(&job.id, |record| {
|
||||
record.status = "running";
|
||||
@@ -602,15 +602,31 @@ pub(super) fn run_task_worker(
|
||||
},
|
||||
)
|
||||
};
|
||||
run_result
|
||||
.and_then(|report| {
|
||||
if job.kind == TaskKind::Verify {
|
||||
let run_result = if job.kind == TaskKind::Verify {
|
||||
match run_result {
|
||||
Ok(report) => {
|
||||
bat_infrastructure::verify_and_record_official_distribution_attestation(
|
||||
&job.config,
|
||||
)?;
|
||||
service.attestation_max_age_seconds(),
|
||||
)
|
||||
.map(|_| report)
|
||||
}
|
||||
Ok(report)
|
||||
})
|
||||
Err(error) => {
|
||||
// If the verifier failed before returning its report
|
||||
// (for example, a malformed manifest), make a best
|
||||
// effort to revoke the previous ready generation.
|
||||
let _ =
|
||||
bat_infrastructure::verify_and_record_official_distribution_attestation(
|
||||
&job.config,
|
||||
service.attestation_max_age_seconds(),
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
run_result
|
||||
};
|
||||
run_result
|
||||
.map(|report| serde_json::to_value(&report).map_err(anyhow::Error::from))
|
||||
.and_then(|result| result)
|
||||
};
|
||||
|
||||
@@ -80,7 +80,8 @@ pub use official_changes::{
|
||||
OFFICIAL_RESOURCE_CHANGES_VERSION,
|
||||
};
|
||||
pub use official_download::{
|
||||
official_distribution_mapping_identity, read_cas_reuse_reference_manifest_at,
|
||||
official_distribution_mapping_identity, official_distribution_max_age_for_durations,
|
||||
official_distribution_max_age_seconds, read_cas_reuse_reference_manifest_at,
|
||||
read_download_manifest_at, release_cas_reuse_references, DownloadError,
|
||||
OfficialCasReuseReferenceManifest, OfficialDistributionAttestation, OfficialDownloadManifest,
|
||||
OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem,
|
||||
@@ -89,9 +90,9 @@ pub use official_download::{
|
||||
OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress,
|
||||
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
|
||||
OfficialResourcePullStatus, OfficialResourceReuseWarning, OfficialResourceVerification,
|
||||
DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS, DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS,
|
||||
OFFICIAL_CAS_REUSE_REFERENCES_FILE, OFFICIAL_DISTRIBUTION_ATTESTATION_FILE,
|
||||
OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS, OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION,
|
||||
OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||
OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION, OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||
};
|
||||
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
||||
pub use official_launcher::{
|
||||
|
||||
@@ -27,7 +27,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。
|
||||
///
|
||||
@@ -75,8 +75,10 @@ pub const OFFICIAL_DISTRIBUTION_PUBLICATION_FILE: &str = "official-distribution-
|
||||
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;
|
||||
/// Default interval used by the Rust official watch verifier.
|
||||
pub const DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS: u64 = 60 * 60;
|
||||
/// Default retry interval used after an official watch failure.
|
||||
pub const DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS: u64 = 60;
|
||||
/// 记录一个已发布官方 release 获取的 CAS 引用。
|
||||
pub const OFFICIAL_CAS_REUSE_REFERENCES_FILE: &str = "official-cas-reuse-references.json";
|
||||
const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1;
|
||||
@@ -87,6 +89,40 @@ const CAS_OWNER_SCOPE_FILE: &str = ".cas-owner-scope";
|
||||
const CAS_OWNER_SCOPE_VERSION: u32 = 1;
|
||||
static CAS_OWNERSHIP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// Returns the only freshness policy used for official distribution
|
||||
/// attestations.
|
||||
///
|
||||
/// The window covers two complete verification intervals plus one retry
|
||||
/// interval, so a healthy attestation cannot remain authoritative after the
|
||||
/// verifier has missed an entire cycle and its retry.
|
||||
pub const fn official_distribution_max_age_seconds(
|
||||
verification_interval_seconds: u64,
|
||||
error_retry_seconds: u64,
|
||||
) -> u64 {
|
||||
verification_interval_seconds
|
||||
.saturating_mul(2)
|
||||
.saturating_add(error_retry_seconds)
|
||||
}
|
||||
|
||||
/// Converts watch durations to the seconds used by the persisted freshness
|
||||
/// policy, rounding partial seconds up so a non-zero duration never becomes a
|
||||
/// zero-second policy.
|
||||
pub fn official_distribution_max_age_for_durations(
|
||||
verification_interval: Duration,
|
||||
error_retry: Duration,
|
||||
) -> u64 {
|
||||
fn ceil_seconds(duration: Duration) -> u64 {
|
||||
duration
|
||||
.as_secs()
|
||||
.saturating_add(u64::from(duration.subsec_nanos() != 0))
|
||||
}
|
||||
|
||||
official_distribution_max_age_seconds(
|
||||
ceil_seconds(verification_interval),
|
||||
ceil_seconds(error_retry),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CasOwnerScopeState {
|
||||
version: u32,
|
||||
@@ -664,10 +700,12 @@ pub(crate) fn read_official_distribution_attestation_at(
|
||||
/// passed; this function itself never turns a partial audit into a healthy
|
||||
/// result.
|
||||
pub(crate) fn write_official_distribution_attestation_at(
|
||||
release_root: &Path,
|
||||
storage_root: &Path,
|
||||
canonical_resource_root: &Path,
|
||||
official_release_id: &str,
|
||||
integrity_status: &str,
|
||||
diagnostics: Vec<String>,
|
||||
max_age_seconds: u64,
|
||||
mut diagnostics: Vec<String>,
|
||||
) -> Result<OfficialDistributionAttestation, String> {
|
||||
if !matches!(
|
||||
integrity_status,
|
||||
@@ -677,23 +715,80 @@ pub(crate) fn write_official_distribution_attestation_at(
|
||||
"不支持的官方 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)?
|
||||
if max_age_seconds == 0 {
|
||||
return Err("官方 distribution attestation freshness window 必须大于 0".to_string());
|
||||
}
|
||||
ensure_safe_directory_path(storage_root, "官方 distribution attestation 存储目录")?;
|
||||
ensure_safe_directory_path(
|
||||
canonical_resource_root,
|
||||
"官方 distribution canonical 根目录",
|
||||
)?;
|
||||
ensure_path_within_root(&ownership_scope_root(storage_root), canonical_resource_root)?;
|
||||
let previous = match read_official_distribution_attestation_at(storage_root) {
|
||||
Ok(previous) => previous,
|
||||
Err(error) if integrity_status == "invalid" => {
|
||||
diagnostics.push(format!(
|
||||
"上一代 distribution attestation 不可读取,重新记录 invalid:{error}"
|
||||
));
|
||||
None
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let previous_generation = previous
|
||||
.as_ref()
|
||||
.map(|previous| previous.verification_generation)
|
||||
.unwrap_or(0);
|
||||
let anchor =
|
||||
match verify_official_distribution_publication_at(storage_root, official_release_id) {
|
||||
Ok(Some(anchor)) => anchor,
|
||||
Ok(None) if integrity_status == "invalid" => {
|
||||
let Some(previous) = previous.as_ref() else {
|
||||
return Err(format!(
|
||||
"官方 distribution attestation 缺少 publication anchor:{}",
|
||||
storage_root.display()
|
||||
));
|
||||
};
|
||||
diagnostics
|
||||
.push("publication anchor 缺失,沿用上一代 identity 记录 invalid".to_string());
|
||||
OfficialDistributionPublicationAnchor {
|
||||
version: OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION,
|
||||
official_release_id: official_release_id.to_string(),
|
||||
mapping_identity: previous.mapping_identity.clone(),
|
||||
manifest_identity: previous.manifest_identity.clone(),
|
||||
entry_count: previous.entry_count,
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(format!(
|
||||
"官方 distribution attestation 缺少 publication anchor:{}",
|
||||
storage_root.display()
|
||||
));
|
||||
}
|
||||
Err(error) if integrity_status == "invalid" => {
|
||||
let Some(previous) = previous.as_ref() else {
|
||||
return Err(format!(
|
||||
"无法记录 invalid distribution attestation:{error}"
|
||||
));
|
||||
};
|
||||
diagnostics.push(format!(
|
||||
"publication anchor 验证失败,沿用上一代 identity:{error}"
|
||||
));
|
||||
OfficialDistributionPublicationAnchor {
|
||||
version: OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION,
|
||||
official_release_id: official_release_id.to_string(),
|
||||
mapping_identity: previous.mapping_identity.clone(),
|
||||
manifest_identity: previous.manifest_identity.clone(),
|
||||
entry_count: previous.entry_count,
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
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(),
|
||||
resource_root: canonical_resource_root.to_path_buf(),
|
||||
publication_identity: official_distribution_publication_identity(&anchor),
|
||||
mapping_identity: anchor.mapping_identity,
|
||||
manifest_identity: anchor.manifest_identity,
|
||||
@@ -704,11 +799,11 @@ pub(crate) fn write_official_distribution_attestation_at(
|
||||
ready: integrity_status == "verified",
|
||||
verification_generation: previous_generation.saturating_add(1),
|
||||
verified_at,
|
||||
max_age_seconds: OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS,
|
||||
max_age_seconds,
|
||||
diagnostics,
|
||||
};
|
||||
let path = release_root.join(OFFICIAL_DISTRIBUTION_ATTESTATION_FILE);
|
||||
ensure_safe_file_target(release_root, &path, "官方 distribution attestation")?;
|
||||
let path = storage_root.join(OFFICIAL_DISTRIBUTION_ATTESTATION_FILE);
|
||||
ensure_safe_file_target(storage_root, &path, "官方 distribution attestation")?;
|
||||
let bytes = serde_json::to_vec_pretty(&attestation)
|
||||
.map_err(|error| format!("序列化官方 distribution attestation 失败:{error}"))?;
|
||||
write_file_atomic(
|
||||
@@ -4036,6 +4131,18 @@ exit 22
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_distribution_freshness_policy_is_cadence_bound() {
|
||||
assert_eq!(official_distribution_max_age_seconds(3600, 60), 7260);
|
||||
assert_eq!(
|
||||
official_distribution_max_age_for_durations(
|
||||
Duration::from_millis(1500),
|
||||
Duration::from_millis(1),
|
||||
),
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_distribution_mapping_identity_is_deterministic_and_sensitive() {
|
||||
let entries = [
|
||||
|
||||
@@ -14,9 +14,9 @@ use crate::official_changes::{
|
||||
OfficialResourceChangeSummary,
|
||||
};
|
||||
use crate::official_download::{
|
||||
write_official_distribution_attestation_at, write_official_distribution_publication_anchor_at,
|
||||
OfficialDistributionAttestation, OFFICIAL_CAS_REUSE_REFERENCES_FILE,
|
||||
OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||
official_distribution_max_age_for_durations, 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::{
|
||||
resolve_game_main_config_source, OfficialGameMainConfigSelectedSource,
|
||||
@@ -1213,13 +1213,48 @@ impl OfficialPublishLayout {
|
||||
}
|
||||
|
||||
/// Official update runner.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OfficialUpdateService;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OfficialUpdateService {
|
||||
attestation_max_age_seconds: u64,
|
||||
}
|
||||
|
||||
impl Default for OfficialUpdateService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl OfficialUpdateService {
|
||||
/// Creates an official update runner.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self {
|
||||
attestation_max_age_seconds: official_distribution_max_age_for_durations(
|
||||
std::time::Duration::from_secs(
|
||||
crate::official_download::DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS,
|
||||
),
|
||||
std::time::Duration::from_secs(
|
||||
crate::official_download::DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an update runner using the daemon's actual watch cadence.
|
||||
pub fn with_verification_cadence(
|
||||
verification_interval: std::time::Duration,
|
||||
error_retry: std::time::Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
attestation_max_age_seconds: official_distribution_max_age_for_durations(
|
||||
verification_interval,
|
||||
error_retry,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the freshness window persisted with each attestation.
|
||||
pub fn attestation_max_age_seconds(&self) -> u64 {
|
||||
self.attestation_max_age_seconds
|
||||
}
|
||||
|
||||
/// Executes one official update run.
|
||||
@@ -1607,11 +1642,24 @@ impl OfficialUpdateService {
|
||||
fetcher.download_manifest_path().display()
|
||||
),
|
||||
));
|
||||
Some(
|
||||
fetcher
|
||||
.audit_local_manifest(&pull_plan)
|
||||
.map_err(anyhow::Error::msg)?,
|
||||
)
|
||||
match fetcher.audit_local_manifest(&pull_plan) {
|
||||
Ok(audit) => Some(audit),
|
||||
Err(error) => {
|
||||
if !config.dry_run && has_current_pointer {
|
||||
let release_id = version_id_from_path(&active_resource_root)
|
||||
.unwrap_or_else(|| fallback_version_id(¤t_update_snapshot));
|
||||
let _ = write_official_distribution_attestation_at(
|
||||
&active_resource_root,
|
||||
&active_resource_root,
|
||||
&release_id,
|
||||
"invalid",
|
||||
self.attestation_max_age_seconds,
|
||||
vec![format!("本地 manifest 审计失败:{error}")],
|
||||
);
|
||||
}
|
||||
return Err(anyhow::Error::msg(error));
|
||||
}
|
||||
}
|
||||
} else if config.audit_local {
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"audit",
|
||||
@@ -1673,6 +1721,31 @@ impl OfficialUpdateService {
|
||||
check_shutdown_requested(&mut should_cancel)?;
|
||||
let active_release_id = version_id_from_path(&active_resource_root)
|
||||
.unwrap_or_else(|| fallback_version_id(¤t_update_snapshot));
|
||||
let mut local_attestation_invalidated = false;
|
||||
if !config.dry_run
|
||||
&& has_current_pointer
|
||||
&& local_audit.as_ref().is_some_and(|audit| !audit.is_clean())
|
||||
{
|
||||
let diagnostics = local_audit
|
||||
.as_ref()
|
||||
.map(|audit| {
|
||||
vec![format!(
|
||||
"本地 manifest 审计失败:{} 项需要修复",
|
||||
audit.repair_needed_count()
|
||||
)]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
write_official_distribution_attestation_at(
|
||||
&active_resource_root,
|
||||
&active_resource_root,
|
||||
&active_release_id,
|
||||
"invalid",
|
||||
self.attestation_max_age_seconds,
|
||||
diagnostics,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
local_attestation_invalidated = true;
|
||||
}
|
||||
let localized_info = localized_release_info_for(config, Some(active_release_id.as_str()));
|
||||
let mut report = OfficialUpdateReport {
|
||||
update_status: if should_download {
|
||||
@@ -1786,27 +1859,31 @@ 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)?;
|
||||
if !local_attestation_invalidated {
|
||||
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_resource_root,
|
||||
&active_release_id,
|
||||
integrity_status,
|
||||
self.attestation_max_age_seconds,
|
||||
diagnostics,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
}
|
||||
}
|
||||
let active_launcher_bootstrap_path =
|
||||
active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE);
|
||||
@@ -2040,8 +2117,10 @@ impl OfficialUpdateService {
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
write_official_distribution_attestation_at(
|
||||
&publish_plan.staging_path,
|
||||
&publish_plan.version_path,
|
||||
&publish_plan.id,
|
||||
"verified",
|
||||
self.attestation_max_age_seconds,
|
||||
Vec::new(),
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
@@ -2218,6 +2297,7 @@ impl OfficialUpdateService {
|
||||
/// resource artifacts.
|
||||
pub fn verify_and_record_official_distribution_attestation(
|
||||
config: &OfficialUpdateConfig,
|
||||
max_age_seconds: u64,
|
||||
) -> anyhow::Result<OfficialDistributionAttestation> {
|
||||
let version_state = read_version_state(&config.version_state_path())?
|
||||
.ok_or_else(|| anyhow::anyhow!("官方版本状态不存在,无法记录 distribution attestation"))?;
|
||||
@@ -2237,11 +2317,27 @@ pub fn verify_and_record_official_distribution_attestation(
|
||||
record.resource_root.display()
|
||||
));
|
||||
}
|
||||
let verification =
|
||||
let verification_result =
|
||||
OfficialResourcePullService::with_curl_command(&resource_root, &config.curl_command)
|
||||
.with_proxy_config(config.curl_proxy.clone())
|
||||
.verify_local_download_manifest()
|
||||
.verify_local_download_manifest();
|
||||
let verification = match verification_result {
|
||||
Ok(verification) => verification,
|
||||
Err(error) => {
|
||||
write_official_distribution_attestation_at(
|
||||
&resource_root,
|
||||
&resource_root,
|
||||
&record.id,
|
||||
"invalid",
|
||||
max_age_seconds,
|
||||
vec![format!("本地 manifest 验证失败:{error}")],
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
return Err(anyhow::anyhow!(
|
||||
"本地 manifest 验证失败,已立即使当前 distribution attestation 失效:{error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
let diagnostics = verification
|
||||
.items
|
||||
.iter()
|
||||
@@ -2254,9 +2350,11 @@ pub fn verify_and_record_official_distribution_attestation(
|
||||
"invalid"
|
||||
};
|
||||
write_official_distribution_attestation_at(
|
||||
&resource_root,
|
||||
&resource_root,
|
||||
&record.id,
|
||||
integrity_status,
|
||||
max_age_seconds,
|
||||
diagnostics,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)
|
||||
@@ -4977,6 +5075,227 @@ mod tests {
|
||||
assert_eq!(read_version_state(&path).unwrap(), Some(state));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn publication_attestation_keeps_canonical_root_across_staging_rename() {
|
||||
use crate::official_download::{
|
||||
official_distribution_max_age_seconds, write_official_distribution_attestation_at,
|
||||
write_official_distribution_publication_anchor_at, OfficialDownloadManifest,
|
||||
OfficialDownloadManifestEntry,
|
||||
};
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let root = temp.path().join("official");
|
||||
let layout = OfficialPublishLayout::new(&root);
|
||||
let staging = layout.staging_dir.join("release-a");
|
||||
let version = layout.versions_dir.join("release-a");
|
||||
fs::create_dir_all(&staging).unwrap();
|
||||
fs::create_dir_all(&layout.versions_dir).unwrap();
|
||||
|
||||
let payload = b"official";
|
||||
fs::write(staging.join("data.bin"), payload).unwrap();
|
||||
let url = "https://example.invalid/data.bin".to_string();
|
||||
let mut manifest = OfficialDownloadManifest {
|
||||
entries: [(
|
||||
url.clone(),
|
||||
OfficialDownloadManifestEntry {
|
||||
url: url.clone(),
|
||||
destination: "data.bin".to_string(),
|
||||
bytes: payload.len() as u64,
|
||||
blake3: blake3::hash(payload).to_hex().to_string(),
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..OfficialDownloadManifest::default()
|
||||
};
|
||||
manifest.destination_index = [("data.bin".to_string(), url)].into_iter().collect();
|
||||
manifest.distribution_mapping_identity =
|
||||
Some(crate::official_download::official_distribution_mapping_identity(&manifest));
|
||||
fs::write(
|
||||
staging.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
write_official_distribution_publication_anchor_at(&staging, "release-a").unwrap();
|
||||
let max_age = official_distribution_max_age_seconds(3600, 60);
|
||||
let before_publish = write_official_distribution_attestation_at(
|
||||
&staging,
|
||||
&version,
|
||||
"release-a",
|
||||
"verified",
|
||||
max_age,
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(before_publish.resource_root, version);
|
||||
assert_eq!(before_publish.verification_generation, 1);
|
||||
|
||||
let plan = OfficialPublishPlan {
|
||||
id: "release-a".to_string(),
|
||||
staging_path: staging,
|
||||
version_path: version.clone(),
|
||||
reuse_existing_staging: false,
|
||||
};
|
||||
let published = layout.publish(&plan).unwrap();
|
||||
assert_eq!(published, version);
|
||||
let after_rename =
|
||||
crate::official_download::read_official_distribution_attestation_at(&version)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(after_rename.resource_root, version);
|
||||
assert_eq!(
|
||||
after_rename.verification_generation,
|
||||
before_publish.verification_generation
|
||||
);
|
||||
|
||||
let snapshot_path = version.join(OFFICIAL_SYNC_SNAPSHOT_FILE);
|
||||
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
|
||||
write_snapshot(&snapshot_path, &snapshot).unwrap();
|
||||
let state = OfficialVersionState {
|
||||
current_completed_version: Some(OfficialVersionRecord {
|
||||
id: "release-a".to_string(),
|
||||
app_version: snapshot.app_version.clone(),
|
||||
bundle_version: snapshot.bundle_version.clone(),
|
||||
addressables_root: snapshot.addressables_root.clone(),
|
||||
resource_root: version.clone(),
|
||||
snapshot_path,
|
||||
staging_path: None,
|
||||
version_path: Some(version.clone()),
|
||||
started_unix_seconds: Some(1),
|
||||
completed_unix_seconds: Some(2),
|
||||
}),
|
||||
..OfficialVersionState::default()
|
||||
};
|
||||
write_version_state(&root.join(OFFICIAL_VERSION_STATE_FILE), &state).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_link(root.join(OFFICIAL_CURRENT_LINK)).unwrap(),
|
||||
Path::new(OFFICIAL_VERSIONS_DIR).join("release-a")
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.current_completed_version
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.resource_root,
|
||||
after_rename.resource_root
|
||||
);
|
||||
let report = crate::release_ops::build_official_distribution_attestation(&root).unwrap();
|
||||
assert!(report.available);
|
||||
assert!(report.ready);
|
||||
assert_eq!(report.release_id, "release-a");
|
||||
assert_eq!(report.resource_root, version.display().to_string());
|
||||
assert_eq!(report.verification_generation, 1);
|
||||
|
||||
// Keep this assertion explicit: current is the only pointer used by
|
||||
// the read path, and the attestation never stores the staging path.
|
||||
assert!(!after_rename.resource_root.starts_with(layout.staging_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_local_verification_invalidates_previous_ready_generation() {
|
||||
use crate::official_download::{
|
||||
official_distribution_max_age_seconds, write_official_distribution_attestation_at,
|
||||
write_official_distribution_publication_anchor_at, OfficialDownloadManifest,
|
||||
OfficialDownloadManifestEntry,
|
||||
};
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let root = temp.path().join("official");
|
||||
let version = root.join(OFFICIAL_VERSIONS_DIR).join("release-a");
|
||||
fs::create_dir_all(&version).unwrap();
|
||||
let payload = b"official";
|
||||
let url = "https://example.invalid/data.bin".to_string();
|
||||
let mut manifest = OfficialDownloadManifest {
|
||||
entries: [(
|
||||
url.clone(),
|
||||
OfficialDownloadManifestEntry {
|
||||
url,
|
||||
destination: "data.bin".to_string(),
|
||||
bytes: payload.len() as u64,
|
||||
blake3: blake3::hash(payload).to_hex().to_string(),
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..OfficialDownloadManifest::default()
|
||||
};
|
||||
manifest.destination_index = [(
|
||||
"data.bin".to_string(),
|
||||
"https://example.invalid/data.bin".to_string(),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect();
|
||||
manifest.distribution_mapping_identity =
|
||||
Some(crate::official_download::official_distribution_mapping_identity(&manifest));
|
||||
fs::write(
|
||||
version.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(version.join("data.bin"), payload).unwrap();
|
||||
write_official_distribution_publication_anchor_at(&version, "release-a").unwrap();
|
||||
let max_age = official_distribution_max_age_seconds(3600, 60);
|
||||
let initial = write_official_distribution_attestation_at(
|
||||
&version,
|
||||
&version,
|
||||
"release-a",
|
||||
"verified",
|
||||
max_age,
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
symlink(
|
||||
Path::new(OFFICIAL_VERSIONS_DIR).join("release-a"),
|
||||
root.join(OFFICIAL_CURRENT_LINK),
|
||||
)
|
||||
.unwrap();
|
||||
let snapshot_path = version.join(OFFICIAL_SYNC_SNAPSHOT_FILE);
|
||||
write_snapshot(
|
||||
&snapshot_path,
|
||||
&OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None),
|
||||
)
|
||||
.unwrap();
|
||||
write_version_state(
|
||||
&root.join(OFFICIAL_VERSION_STATE_FILE),
|
||||
&OfficialVersionState {
|
||||
current_completed_version: Some(OfficialVersionRecord {
|
||||
id: "release-a".to_string(),
|
||||
app_version: "app".to_string(),
|
||||
bundle_version: None,
|
||||
addressables_root: "root".to_string(),
|
||||
resource_root: version.clone(),
|
||||
snapshot_path,
|
||||
staging_path: None,
|
||||
version_path: Some(version.clone()),
|
||||
started_unix_seconds: Some(1),
|
||||
completed_unix_seconds: Some(2),
|
||||
}),
|
||||
..OfficialVersionState::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(version.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE), b"{malformed").unwrap();
|
||||
let config = OfficialUpdateConfig {
|
||||
output_root: root,
|
||||
..OfficialUpdateConfig::default()
|
||||
};
|
||||
assert!(verify_and_record_official_distribution_attestation(&config, max_age).is_err());
|
||||
let invalid = crate::official_download::read_official_distribution_attestation_at(&version)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
invalid.verification_generation,
|
||||
initial.verification_generation + 1
|
||||
);
|
||||
assert_eq!(invalid.integrity_status, "invalid");
|
||||
assert!(!invalid.ready);
|
||||
assert!(invalid.verified_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_state_tracks_in_progress_success_and_failure() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -15,7 +15,6 @@ use crate::official_download::{
|
||||
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::{
|
||||
@@ -377,7 +376,7 @@ pub fn build_official_distribution_attestation(
|
||||
ready: false,
|
||||
verification_generation: 0,
|
||||
verified_at: None,
|
||||
max_age_seconds: OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS,
|
||||
max_age_seconds: 0,
|
||||
diagnostics: vec!["没有当前已发布官方 release".to_string()],
|
||||
});
|
||||
};
|
||||
@@ -397,15 +396,17 @@ pub fn build_official_distribution_attestation(
|
||||
ready: false,
|
||||
verification_generation: 0,
|
||||
verified_at: None,
|
||||
max_age_seconds: OFFICIAL_DISTRIBUTION_ATTESTATION_MAX_AGE_SECONDS,
|
||||
max_age_seconds: 0,
|
||||
diagnostics: Vec::new(),
|
||||
};
|
||||
let mut structural_invalid = false;
|
||||
|
||||
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()) {
|
||||
structural_invalid = true;
|
||||
report.integrity_status = "invalid".to_string();
|
||||
report.status = "invalid".to_string();
|
||||
report.status_code = "distribution.attestation_invalid".to_string();
|
||||
@@ -415,6 +416,7 @@ pub fn build_official_distribution_attestation(
|
||||
));
|
||||
}
|
||||
if let Err(error) = ensure_path_within_root(official_root, &record.resource_root) {
|
||||
structural_invalid = true;
|
||||
report.integrity_status = "invalid".to_string();
|
||||
report.status = "invalid".to_string();
|
||||
report.status_code = "distribution.attestation_invalid".to_string();
|
||||
@@ -423,6 +425,7 @@ pub fn build_official_distribution_attestation(
|
||||
if let Err(error) =
|
||||
ensure_safe_directory_path(&record.resource_root, "当前官方 distribution 根目录")
|
||||
{
|
||||
structural_invalid = true;
|
||||
report.integrity_status = "invalid".to_string();
|
||||
report.status = "invalid".to_string();
|
||||
report.status_code = "distribution.attestation_invalid".to_string();
|
||||
@@ -478,13 +481,17 @@ pub fn build_official_distribution_attestation(
|
||||
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.max_age_seconds = attestation.max_age_seconds;
|
||||
report.diagnostics.extend(attestation.diagnostics);
|
||||
|
||||
if structural_invalid {
|
||||
report.integrity_status = "invalid".to_string();
|
||||
report.status = "invalid".to_string();
|
||||
report.status_code = "distribution.attestation_invalid".to_string();
|
||||
report.ready = false;
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
let identity_matches = anchor.as_ref().is_some_and(|anchor| {
|
||||
attestation.channel == "official"
|
||||
&& attestation.official_release_id == record.id
|
||||
@@ -506,6 +513,28 @@ pub fn build_official_distribution_attestation(
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
if report.max_age_seconds == 0 {
|
||||
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 缺失或为 0".to_string());
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
if report.verification_generation == 0 {
|
||||
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 verification generation 缺失或为 0".to_string());
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
if report.integrity_status == "verified" {
|
||||
let fresh = report.verified_at.is_some_and(|verified_at| {
|
||||
let now = SystemTime::now()
|
||||
@@ -2049,9 +2078,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
crate::official_download::write_official_distribution_attestation_at(
|
||||
&version,
|
||||
&version,
|
||||
"official-v1",
|
||||
"verified",
|
||||
crate::official_download::official_distribution_max_age_seconds(3600, 60),
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -2061,6 +2092,22 @@ mod tests {
|
||||
assert_eq!(healthy.release_id, "official-v1");
|
||||
assert!(healthy.verification_generation > 0);
|
||||
|
||||
fs::remove_file(official_root.join(OFFICIAL_CURRENT_LINK)).unwrap();
|
||||
symlink(
|
||||
Path::new(OFFICIAL_VERSIONS_DIR).join("wrong-release"),
|
||||
official_root.join(OFFICIAL_CURRENT_LINK),
|
||||
)
|
||||
.unwrap();
|
||||
let pointer_mismatch = build_official_distribution_attestation(&official_root).unwrap();
|
||||
assert!(!pointer_mismatch.ready);
|
||||
assert_eq!(pointer_mismatch.integrity_status, "invalid");
|
||||
fs::remove_file(official_root.join(OFFICIAL_CURRENT_LINK)).unwrap();
|
||||
symlink(
|
||||
Path::new(OFFICIAL_VERSIONS_DIR).join("official-v1"),
|
||||
official_root.join(OFFICIAL_CURRENT_LINK),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 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();
|
||||
@@ -2068,9 +2115,11 @@ mod tests {
|
||||
assert!(still_authorized.ready);
|
||||
|
||||
crate::official_download::write_official_distribution_attestation_at(
|
||||
&version,
|
||||
&version,
|
||||
"official-v1",
|
||||
"invalid",
|
||||
crate::official_download::official_distribution_max_age_seconds(3600, 60),
|
||||
vec!["other.bin: size_or_hash_mismatch".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -2096,6 +2145,17 @@ mod tests {
|
||||
let stale = build_official_distribution_attestation(&official_root).unwrap();
|
||||
assert!(!stale.ready);
|
||||
assert_eq!(stale.integrity_status, "stale");
|
||||
|
||||
fresh.verified_at = Some(1);
|
||||
fresh.max_age_seconds = 0;
|
||||
fs::write(
|
||||
version.join(crate::OFFICIAL_DISTRIBUTION_ATTESTATION_FILE),
|
||||
serde_json::to_vec(&fresh).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let zero_window = build_official_distribution_attestation(&official_root).unwrap();
|
||||
assert!(!zero_window.ready);
|
||||
assert_eq!(zero_window.integrity_status, "stale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user