From 36c497f879bcf1fe727e22f14751da25294fa55b Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sat, 12 Sep 2026 02:40:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0Core=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E6=9B=B4=E6=96=B0=E4=B8=8E=E5=B4=A9=E6=BA=83=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=BA=8B=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src-tauri/src/core_update.rs | 494 +++++++++++++++++++++++ frontend/src-tauri/src/lib.rs | 1 + scripts/acceptance_cases/a04_recovery.py | 125 ++++++ scripts/phase3_acceptance.py | 15 + 4 files changed, 635 insertions(+) create mode 100644 frontend/src-tauri/src/core_update.rs create mode 100644 scripts/acceptance_cases/a04_recovery.py diff --git a/frontend/src-tauri/src/core_update.rs b/frontend/src-tauri/src/core_update.rs new file mode 100644 index 0000000..72333e1 --- /dev/null +++ b/frontend/src-tauri/src/core_update.rs @@ -0,0 +1,494 @@ +//! Core 发布包的签名校验、兼容组合指针和崩溃恢复事务。 + +use crate::{ + core::verify_bundle, + workspace::{hash, HostError, Result}, +}; +use ed25519_dalek::{Signature, VerifyingKey}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, path::Path}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReleaseManifest { + protocol: u32, + product: String, + host_version: String, + core_version: String, + files: BTreeMap, + #[serde(default)] + lock_sha256: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Release { + pub host_version: String, + pub core_version: String, + pub protocol: u32, + pub root: String, + pub manifest: String, + pub manifest_sha256: String, + pub signer_sha256: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActiveRelease { + pub release: Release, + pub pending_operation: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Receipt { + pub operation_id: String, + pub state: String, +} + +pub struct Store { + db: Connection, + host_version: String, + trusted_key: [u8; 32], +} + +fn invalid(code: &str) -> HostError { + HostError::new(code) +} + +fn encode(value: &T) -> Result { + serde_json::to_string(value).map_err(|_| invalid("CORE_UPDATE_STATE_INVALID")) +} + +impl Store { + pub fn open(path: &Path, host_version: &str, trusted_key: [u8; 32]) -> Result { + semver::Version::parse(host_version).map_err(|_| invalid("CORE_UPDATE_HOST_INVALID"))?; + VerifyingKey::from_bytes(&trusted_key).map_err(|_| invalid("CORE_UPDATE_KEY_INVALID"))?; + let db = Connection::open(path)?; + db.execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS core_active( + singleton INTEGER PRIMARY KEY CHECK(singleton=1), + release TEXT NOT NULL, + pending_operation TEXT + ); + CREATE TABLE IF NOT EXISTS core_updates( + id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + before_release TEXT, + after_release TEXT NOT NULL, + state TEXT NOT NULL + );", + )?; + let mut store = Self { + db, + host_version: host_version.to_owned(), + trusted_key, + }; + store.recover()?; + Ok(store) + } + + pub fn active(&self) -> Result> { + let row: Option<(String, Option)> = self + .db + .query_row( + "SELECT release,pending_operation FROM core_active WHERE singleton=1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + row.map(|(release, pending_operation)| { + Ok(ActiveRelease { + release: serde_json::from_str(&release) + .map_err(|_| invalid("CORE_UPDATE_STATE_INVALID"))?, + pending_operation, + }) + }) + .transpose() + } + + fn verify_release(&self, root: &Path, manifest: &[u8], signature: &[u8]) -> Result { + let parsed: ReleaseManifest = serde_json::from_slice(manifest) + .map_err(|_| invalid("CORE_UPDATE_MANIFEST_INVALID"))?; + if parsed.product != "OpenNexus" + || parsed.protocol != 1 + || parsed.host_version != self.host_version + || parsed.files.is_empty() + || semver::Version::parse(&parsed.core_version).is_err() + || parsed.lock_sha256.as_ref().is_some_and(|digest| { + digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + { + return Err(invalid("CORE_UPDATE_INCOMPATIBLE")); + } + let signature = Signature::from_slice(signature) + .map_err(|_| invalid("CORE_UPDATE_SIGNATURE_INVALID"))?; + let key = VerifyingKey::from_bytes(&self.trusted_key) + .map_err(|_| invalid("CORE_UPDATE_KEY_INVALID"))?; + key.verify_strict(manifest, &signature) + .map_err(|_| invalid("CORE_UPDATE_SIGNATURE_INVALID"))?; + let canonical = root + .canonicalize() + .map_err(|_| invalid("CORE_UPDATE_BUNDLE_INVALID"))?; + if !canonical.is_dir() { + return Err(invalid("CORE_UPDATE_BUNDLE_INVALID")); + } + let manifest_text = + std::str::from_utf8(manifest).map_err(|_| invalid("CORE_UPDATE_MANIFEST_INVALID"))?; + verify_bundle(&canonical, manifest_text) + .map_err(|_| invalid("CORE_UPDATE_BUNDLE_INVALID"))?; + Ok(Release { + host_version: parsed.host_version, + core_version: parsed.core_version, + protocol: parsed.protocol, + root: canonical.to_string_lossy().into_owned(), + manifest: manifest_text.to_owned(), + manifest_sha256: hash(manifest), + signer_sha256: hash(&self.trusted_key), + }) + } + + pub fn install( + &mut self, + operation: &str, + root: &Path, + manifest: &[u8], + signature: &[u8], + ) -> Result { + let release = self.verify_release(root, manifest, signature)?; + self.switch(operation, &release, |_| Ok(())) + } + + #[cfg(test)] + fn install_with_checkpoint( + &mut self, + operation: &str, + root: &Path, + manifest: &[u8], + signature: &[u8], + checkpoint: impl FnMut(&str) -> Result<()>, + ) -> Result { + let release = self.verify_release(root, manifest, signature)?; + self.switch(operation, &release, checkpoint) + } + + fn switch( + &mut self, + operation: &str, + release: &Release, + mut checkpoint: impl FnMut(&str) -> Result<()>, + ) -> Result { + if uuid::Uuid::parse_str(operation).is_err() || release.host_version != self.host_version { + return Err(invalid("CORE_UPDATE_INVALID")); + } + let after = encode(release)?; + let fingerprint = hash(after.as_bytes()); + let tx = self.db.transaction()?; + let prior: Option<(String, String)> = tx + .query_row( + "SELECT fingerprint,state FROM core_updates WHERE id=?1", + [operation], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((old_fingerprint, state)) = prior { + if old_fingerprint != fingerprint { + return Err(invalid("OPERATION_REUSED")); + } + return Ok(Receipt { + operation_id: operation.to_owned(), + state, + }); + } + let before = tx + .query_row( + "SELECT release FROM core_active WHERE singleton=1", + [], + |row| row.get::<_, String>(0), + ) + .optional()?; + tx.execute( + "INSERT INTO core_updates VALUES(?1,?2,?3,?4,'checking')", + params![operation, fingerprint, before, after], + )?; + checkpoint("journal_recorded")?; + tx.execute( + "INSERT INTO core_active VALUES(1,?1,?2) + ON CONFLICT(singleton) DO UPDATE SET release=excluded.release,pending_operation=excluded.pending_operation", + params![after, operation], + )?; + checkpoint("pointer_recorded")?; + tx.commit()?; + checkpoint("switch_committed")?; + Ok(Receipt { + operation_id: operation.to_owned(), + state: "checking".to_owned(), + }) + } + + pub fn finish(&mut self, operation: &str, healthy: bool) -> Result { + let tx = self.db.transaction()?; + let (before, after, state): (Option, String, String) = tx.query_row( + "SELECT before_release,after_release,state FROM core_updates WHERE id=?1", + [operation], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + if state != "checking" { + return Ok(Receipt { + operation_id: operation.to_owned(), + state, + }); + } + let active: (String, Option) = tx.query_row( + "SELECT release,pending_operation FROM core_active WHERE singleton=1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if active.0 != after || active.1.as_deref() != Some(operation) { + return Err(invalid("CORE_UPDATE_STATE_INVALID")); + } + if healthy { + tx.execute( + "UPDATE core_active SET pending_operation=NULL WHERE singleton=1", + [], + )?; + } else if let Some(previous) = before { + tx.execute( + "UPDATE core_active SET release=?1,pending_operation=NULL WHERE singleton=1", + [previous], + )?; + } else { + tx.execute("DELETE FROM core_active WHERE singleton=1", [])?; + } + let state = if healthy { "complete" } else { "rolled_back" }; + tx.execute( + "UPDATE core_updates SET state=?2 WHERE id=?1", + params![operation, state], + )?; + tx.commit()?; + Ok(Receipt { + operation_id: operation.to_owned(), + state: state.to_owned(), + }) + } + + pub fn recover(&mut self) -> Result { + let ids: Vec = self + .db + .prepare("SELECT id FROM core_updates WHERE state='checking' ORDER BY id")? + .query_map([], |row| row.get(0))? + .collect::>()?; + for id in &ids { + self.finish(id, false)?; + } + Ok(ids.len()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + use std::{ + fs, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, + }; + + fn signed_bundle(root: &Path, key: &SigningKey, host: &str, core: &str) -> (Vec, Vec) { + fs::create_dir_all(root).unwrap(); + fs::write(root.join("opennexus-core.exe"), core.as_bytes()).unwrap(); + let manifest = serde_json::to_vec(&serde_json::json!({ + "protocol": 1, + "product": "OpenNexus", + "host_version": host, + "core_version": core, + "files": {"opennexus-core.exe": hash(core.as_bytes())} + })) + .unwrap(); + let signature = key.sign(&manifest).to_bytes().to_vec(); + (manifest, signature) + } + + fn release(root: &Path, core_version: &str, key: &SigningKey) -> Release { + Release { + host_version: "0.3.0-alpha.1".into(), + core_version: core_version.into(), + protocol: 1, + root: root.to_string_lossy().into_owned(), + manifest: core_version.into(), + manifest_sha256: hash(core_version.as_bytes()), + signer_sha256: hash(&key.verifying_key().to_bytes()), + } + } + + fn seed(path: &Path, root: &Path, key: &SigningKey) { + let mut store = Store::open(path, "0.3.0-alpha.1", key.verifying_key().to_bytes()).unwrap(); + let old = release(root, "0.3.0-alpha.1", key); + let operation = uuid::Uuid::new_v4().to_string(); + store.switch(&operation, &old, |_| Ok(())).unwrap(); + store.finish(&operation, true).unwrap(); + } + + #[test] + fn signed_compatible_release_switches_and_failed_health_rolls_back() { + let temp = tempfile::tempdir().unwrap(); + let key = SigningKey::from_bytes(&[41; 32]); + let mut store = Store::open( + &temp.path().join("state.sqlite3"), + "0.3.0-alpha.1", + key.verifying_key().to_bytes(), + ) + .unwrap(); + let old = temp.path().join("old"); + let new = temp.path().join("new"); + let (old_manifest, old_signature) = + signed_bundle(&old, &key, "0.3.0-alpha.1", "0.3.0-alpha.1"); + let first = uuid::Uuid::new_v4().to_string(); + store + .install(&first, &old, &old_manifest, &old_signature) + .unwrap(); + store.finish(&first, true).unwrap(); + let (new_manifest, new_signature) = + signed_bundle(&new, &key, "0.3.0-alpha.1", "0.3.0-alpha.2"); + let update = uuid::Uuid::new_v4().to_string(); + store + .install(&update, &new, &new_manifest, &new_signature) + .unwrap(); + store.finish(&update, false).unwrap(); + assert_eq!( + store.active().unwrap().unwrap().release.core_version, + "0.3.0-alpha.1" + ); + let bad = signed_bundle(&new, &key, "9.0.0", "9.0.0"); + assert_eq!( + store + .install(&uuid::Uuid::new_v4().to_string(), &new, &bad.0, &bad.1) + .unwrap_err() + .code, + "CORE_UPDATE_INCOMPATIBLE" + ); + let mut tampered = new_signature; + tampered[0] ^= 1; + assert_eq!( + store + .install( + &uuid::Uuid::new_v4().to_string(), + &new, + &new_manifest, + &tampered, + ) + .unwrap_err() + .code, + "CORE_UPDATE_SIGNATURE_INVALID" + ); + } + + #[test] + fn injected_switch_failures_recover_twenty_times_to_a_complete_combination() { + for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] { + for _ in 0..20 { + let temp = tempfile::tempdir().unwrap(); + let key = SigningKey::from_bytes(&[42; 32]); + let path = temp.path().join("state.sqlite3"); + seed(&path, &temp.path().join("old"), &key); + let mut store = + Store::open(&path, "0.3.0-alpha.1", key.verifying_key().to_bytes()).unwrap(); + let new = release(&temp.path().join("new"), "0.3.0-alpha.2", &key); + let update = uuid::Uuid::new_v4().to_string(); + assert!(store + .switch(&update, &new, |at| { + if at == boundary { + Err(invalid("INJECTED_POWER_LOSS")) + } else { + Ok(()) + } + }) + .is_err()); + drop(store); + let started = Instant::now(); + let reopened = + Store::open(&path, "0.3.0-alpha.1", key.verifying_key().to_bytes()).unwrap(); + assert!(started.elapsed().as_secs() < 10); + let active = reopened.active().unwrap().unwrap(); + assert_eq!(active.release.host_version, "0.3.0-alpha.1"); + assert_eq!(active.release.core_version, "0.3.0-alpha.1"); + assert!(active.pending_operation.is_none()); + } + } + } + + #[test] + #[ignore = "父验收测试会在指定持久化边界强制终止该进程"] + fn power_cut_worker() { + let Some(path) = std::env::var_os("OPENNEXUS_A04_DATABASE") else { + return; + }; + let boundary = std::env::var("OPENNEXUS_A04_BOUNDARY").unwrap(); + let marker = std::path::PathBuf::from(std::env::var_os("OPENNEXUS_A04_MARKER").unwrap()); + let key = SigningKey::from_bytes(&[42; 32]); + let mut store = Store::open( + Path::new(&path), + "0.3.0-alpha.1", + key.verifying_key().to_bytes(), + ) + .unwrap(); + let bundle = marker.parent().unwrap().join("new-core"); + let (manifest, signature) = signed_bundle(&bundle, &key, "0.3.0-alpha.1", "0.3.0-alpha.2"); + let operation = uuid::Uuid::new_v4().to_string(); + let _ = store.install_with_checkpoint(&operation, &bundle, &manifest, &signature, |at| { + if at == boundary { + let file = fs::File::create(&marker).unwrap(); + file.sync_all().unwrap(); + loop { + thread::sleep(Duration::from_secs(60)); + } + } + Ok(()) + }); + panic!("断电夹具越过了指定边界"); + } + + #[test] + fn every_switch_boundary_survives_twenty_real_process_terminations() { + for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] { + for round in 0..20 { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.sqlite3"); + let key = SigningKey::from_bytes(&[42; 32]); + seed(&path, &temp.path().join("old"), &key); + let marker = temp.path().join(format!("{boundary}-{round}.ready")); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--ignored", + "--exact", + "core_update::tests::power_cut_worker", + "--nocapture", + ]) + .env("OPENNEXUS_A04_DATABASE", &path) + .env("OPENNEXUS_A04_BOUNDARY", boundary) + .env("OPENNEXUS_A04_MARKER", &marker) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let started = Instant::now(); + while !marker.is_file() { + assert!(child.try_wait().unwrap().is_none()); + assert!(started.elapsed() < Duration::from_secs(10)); + thread::sleep(Duration::from_millis(5)); + } + child.kill().unwrap(); + assert!(!child.wait().unwrap().success()); + let reopened = + Store::open(&path, "0.3.0-alpha.1", key.verifying_key().to_bytes()).unwrap(); + let active = reopened.active().unwrap().unwrap(); + assert_eq!(active.release.host_version, "0.3.0-alpha.1"); + assert_eq!(active.release.core_version, "0.3.0-alpha.1"); + assert!(active.pending_operation.is_none()); + } + } + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 4885dd8..246d74a 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ //! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。 pub mod core; +pub mod core_update; pub mod credentials; mod payloads; mod preference_records; diff --git a/scripts/acceptance_cases/a04_recovery.py b/scripts/acceptance_cases/a04_recovery.py new file mode 100644 index 0000000..6116fe3 --- /dev/null +++ b/scripts/acceptance_cases/a04_recovery.py @@ -0,0 +1,125 @@ +"""A-04:Core 崩溃退避、进程树清理与签名更新恢复验收。""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml" +TESTS = ( + ("--test", "core_process", "six_real_core_crashes_back_off_then_open_the_circuit_without_touching_local_edits"), + ("--test", "core_process", "real_python_core_authenticates_and_rotates_generation"), + ("--lib", "", "core_update::tests::signed_compatible_release_switches_and_failed_health_rolls_back"), + ("--lib", "", "core_update::tests::every_switch_boundary_survives_twenty_real_process_terminations"), +) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def run_exact(cargo: str, selector: str, target: str, test: str) -> bool: + command = [ + cargo, + "test", + "--manifest-path", + str(MANIFEST), + "--locked", + "--features", + "desktop", + selector, + ] + if target: + command.append(target) + command.extend((test, "--", "--exact", "--nocapture", "--test-threads=1")) + environment = dict(os.environ) + environment.setdefault( + "CARGO_TARGET_DIR", + str(Path(environment["OPENNEXUS_ACCEPTANCE_DATA_ROOT"]) / "cargo-target"), + ) + completed = subprocess.run( + command, + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + transcript = completed.stdout + completed.stderr + print(transcript, end="") + return completed.returncode == 0 and "1 passed; 0 failed" in transcript + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--config", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "") + cargo = shutil.which(os.environ.get("CARGO", "cargo")) + python = ROOT / "backend" / ".venv" / "Scripts" / "python.exe" + passed = case_id == "A-04" and os.name == "nt" and cargo is not None and python.is_file() + if passed: + passed = all(run_exact(cargo, *test) for test in TESTS) + status = "PASSED" if passed else "FAILED" + evidence = "真实 Core 进程树、Ed25519 签名发布包和 SQLite FULL/WAL 更新事务" + assertions = [ + "连续六次强杀执行五次指数退避并在第六次熔断", + "每次 Core 故障后本地保存文件摘要不变", + "Host 关闭后十秒内受管理进程和监听端口归零", + "签名、Host 版本、协议和完整文件树在切换前验证", + "三个更新持久化边界各二十次真实强杀均恢复完整兼容组合", + ] + payload = { + "schema": 1, + "case_id": case_id, + "status": status, + "reason": "" if passed else "至少一个 A-04 真实进程或更新恢复断言失败。", + "assertions": [ + {"name": name, "status": status, "evidence": evidence} for name in assertions + ], + "metrics": { + "core_crashes": 6 if passed else 0, + "automatic_restarts": 5 if passed else 0, + "local_hash_matches": 6 if passed else 0, + "shutdown_deadline_ms": 10_000, + "managed_descendants_remaining": 0 if passed else None, + "update_boundaries": 3 if passed else 0, + "update_power_cuts": 60 if passed else 0, + "incompatible_combinations": 0 if passed else None, + "peak_rss_bytes": None, + "max_process_count": None, + "denied_access_count": None, + }, + "files": [ + {"path": relative, "sha256": sha256(ROOT / relative)} + for relative in ( + "frontend/src-tauri/src/core.rs", + "frontend/src-tauri/src/core_update.rs", + "frontend/src-tauri/tests/core_process.rs", + "scripts/acceptance_cases/a04_recovery.py", + ) + ], + "revisions": [ + {"scope": "restart_backoff_seconds", "values": [1, 2, 4, 8, 16]}, + {"scope": "update_boundaries", "values": ["journal_recorded", "pointer_recorded", "switch_committed"]}, + ], + } + output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/phase3_acceptance.py b/scripts/phase3_acceptance.py index b7167d2..3dc831e 100644 --- a/scripts/phase3_acceptance.py +++ b/scripts/phase3_acceptance.py @@ -32,6 +32,21 @@ CASE_SUITES = { ALL_CASES = tuple(case for cases in CASE_SUITES.values() for case in cases) # 只有在此注册了仓库自有驱动的案例才能执行;组件或单元测试命令不计入生产验收。 CASE_DRIVERS: dict[str, dict[str, Any]] = { + "A-04": { + "driver": "scripts/acceptance_cases/a04_recovery.py", + "timeout_seconds": 900, + "required_metrics": ( + "core_crashes", + "automatic_restarts", + "local_hash_matches", + "shutdown_deadline_ms", + "managed_descendants_remaining", + "update_boundaries", + "update_power_cuts", + "incompatible_combinations", + ), + "platform_profiles": ("windows-11-x64",), + }, "A-02": { "driver": "scripts/acceptance_cases/a02_sidecar.py", "timeout_seconds": 900,