diff --git a/frontend/src-tauri/tests/core_bundle.rs b/frontend/src-tauri/tests/core_bundle.rs index 1cafade..119116f 100644 --- a/frontend/src-tauri/tests/core_bundle.rs +++ b/frontend/src-tauri/tests/core_bundle.rs @@ -24,11 +24,12 @@ fn bundle_rejects_modified_missing_and_extra_files() { #[ignore = "requires scripts/build-core.py; run explicitly after building the isolated Core"] fn packaged_core_twenty_cold_starts_use_isolated_data_and_rotate_sessions() { use notesagent_host::core::CoreSupervisor; + use notesagent_host::workspace::Workspace; use std::{ collections::HashSet, io::{Read, Write}, path::Path, - time::Duration, + time::{Duration, Instant}, }; let output = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.build/sidecar"); let bundle = output.join("dist/opennexus-core").canonicalize().unwrap(); @@ -39,7 +40,11 @@ fn packaged_core_twenty_cold_starts_use_isolated_data_and_rotate_sessions() { "opennexus-core" }); let temp = tempfile::tempdir().unwrap(); + let vault = temp.path().join("vault"); + std::fs::create_dir(&vault).unwrap(); + let mut workspace = Workspace::open(&vault).unwrap(); let mut generations = HashSet::new(); + let mut ready_times = Vec::new(); for attempt in 0..20 { let mut core = CoreSupervisor::new( executable.clone(), @@ -48,7 +53,9 @@ fn packaged_core_twenty_cold_starts_use_isolated_data_and_rotate_sessions() { temp.path().join(format!("run-{attempt}")), ) .with_bundle_manifest(manifest.clone()); + let started = Instant::now(); let request = core.request_session("/health").unwrap(); + ready_times.push(started.elapsed()); assert!(generations.insert(request.generation.clone())); let endpoint = request .url @@ -66,8 +73,52 @@ fn packaged_core_twenty_cold_starts_use_isolated_data_and_rotate_sessions() { "packaged health failed on attempt {attempt}" ); assert!(!response.contains(request.authorization.as_str())); + let path = format!("cold-start-{attempt}.md"); + let saved = workspace + .write(&path, "", format!("本地编辑 {attempt}").as_bytes(), "local") + .unwrap(); + assert_eq!(workspace.read(&path).unwrap().entry.hash, saved.hash); drop(stream); drop(core); assert!(std::net::TcpStream::connect(endpoint).is_err()); } + ready_times.sort(); + let p95 = ready_times[18]; + eprintln!("20 次打包 Core 冷启动 ready P95: {p95:?}"); + assert!(p95 <= Duration::from_secs(10), "ready P95 超过 10 秒"); +} + +#[test] +#[ignore = "需要先通过 scripts/build-core.py 构建隔离 Core"] +fn one_byte_tampered_packaged_core_is_refused_twenty_times() { + use notesagent_host::core::CoreSupervisor; + use sha2::{Digest, Sha256}; + let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.build/sidecar"); + let source = output.join("dist/opennexus-core/opennexus-core.exe"); + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("core"); + std::fs::create_dir(&root).unwrap(); + let executable = root.join("opennexus-core.exe"); + std::fs::copy(&source, &executable).unwrap(); + let original = std::fs::read(&executable).unwrap(); + let manifest = serde_json::json!({ + "protocol": 1, + "product": "OpenNexus", + "files": {"opennexus-core.exe": format!("{:x}", Sha256::digest(&original))} + }) + .to_string(); + let mut tampered = original; + tampered[0] ^= 1; + std::fs::write(&executable, tampered).unwrap(); + for attempt in 0..20 { + let mut core = CoreSupervisor::new( + executable.clone(), + vec![], + root.clone(), + temp.path().join(format!("tampered-{attempt}")), + ) + .with_bundle_manifest(manifest.clone()); + assert_eq!(core.start().unwrap_err(), "CORE_INTEGRITY_FAILED"); + assert!(core.process_id().is_none()); + } } diff --git a/scripts/build-core.py b/scripts/build-core.py index a076073..540f885 100644 --- a/scripts/build-core.py +++ b/scripts/build-core.py @@ -4,16 +4,56 @@ 输出保留在当前工作树中已忽略的 .build 目录内。 """ from __future__ import annotations +import argparse import hashlib import json +import os from pathlib import Path +import shutil import subprocess import sys +import tomllib ROOT = Path(__file__).resolve().parents[1] +def arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--release", + action="store_true", + help="生成必须带 Ed25519 签名的生产 Core 发布包", + ) + parser.add_argument( + "--keep-work", + action="store_true", + help="保留可重建的 PyInstaller 中间目录用于诊断", + ) + return parser.parse_args() + + +def sign_release(manifest: bytes, output: Path) -> None: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + key_file = os.environ.get("OPENNEXUS_CORE_SIGNING_KEY_FILE", "") + if not key_file: + raise RuntimeError("release build requires OPENNEXUS_CORE_SIGNING_KEY_FILE") + key_path = Path(key_file).resolve(strict=True) + private = serialization.load_pem_private_key(key_path.read_bytes(), password=None) + if not isinstance(private, Ed25519PrivateKey): + raise RuntimeError("Core signing key must be an Ed25519 PEM private key") + signature = private.sign(manifest) + public = private.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + (output / "manifest.sig").write_bytes(signature) + (output / "public-key.hex").write_text(public.hex() + "\n", encoding="ascii") + + def main(): + options = arguments() output = ROOT / ".build" / "sidecar" output.mkdir(parents=True, exist_ok=True) subprocess.run([ @@ -36,9 +76,25 @@ def main(): if path.is_file(): with path.open("rb") as stream: files[path.relative_to(bundle).as_posix()] = hashlib.file_digest(stream, "sha256").hexdigest() - manifest = {"protocol": 1, "product": "OpenNexus", "files": files, - "lock_sha256": hashlib.sha256((ROOT / "backend" / "uv.lock").read_bytes()).hexdigest()} - (output / "manifest.json").write_text(json.dumps(manifest, sort_keys=True, separators=(",", ":")), encoding="utf-8") + cargo = tomllib.loads((ROOT / "frontend" / "src-tauri" / "Cargo.toml").read_text(encoding="utf-8")) + version = cargo["package"]["version"] + manifest = { + "protocol": 1, + "product": "OpenNexus", + "host_version": version, + "core_version": version, + "files": files, + "lock_sha256": hashlib.sha256((ROOT / "backend" / "uv.lock").read_bytes()).hexdigest(), + } + encoded = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8") + (output / "manifest.json").write_bytes(encoded) + if options.release: + sign_release(encoded, output) + else: + for stale in (output / "manifest.sig", output / "public-key.hex"): + stale.unlink(missing_ok=True) + if not options.keep_work: + shutil.rmtree(output / "work", ignore_errors=True) print(f"Core built: {len(files)} files; manifest: {output / 'manifest.json'}")