build: 补强签名Core发布预检

This commit is contained in:
2026-09-12 02:55:51 +08:00
parent 83e3f34b87
commit e7a651b6d6
2 changed files with 111 additions and 4 deletions
+52 -1
View File
@@ -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"] #[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() { fn packaged_core_twenty_cold_starts_use_isolated_data_and_rotate_sessions() {
use notesagent_host::core::CoreSupervisor; use notesagent_host::core::CoreSupervisor;
use notesagent_host::workspace::Workspace;
use std::{ use std::{
collections::HashSet, collections::HashSet,
io::{Read, Write}, io::{Read, Write},
path::Path, path::Path,
time::Duration, time::{Duration, Instant},
}; };
let output = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.build/sidecar"); let output = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.build/sidecar");
let bundle = output.join("dist/opennexus-core").canonicalize().unwrap(); 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" "opennexus-core"
}); });
let temp = tempfile::tempdir().unwrap(); 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 generations = HashSet::new();
let mut ready_times = Vec::new();
for attempt in 0..20 { for attempt in 0..20 {
let mut core = CoreSupervisor::new( let mut core = CoreSupervisor::new(
executable.clone(), executable.clone(),
@@ -48,7 +53,9 @@ fn packaged_core_twenty_cold_starts_use_isolated_data_and_rotate_sessions() {
temp.path().join(format!("run-{attempt}")), temp.path().join(format!("run-{attempt}")),
) )
.with_bundle_manifest(manifest.clone()); .with_bundle_manifest(manifest.clone());
let started = Instant::now();
let request = core.request_session("/health").unwrap(); let request = core.request_session("/health").unwrap();
ready_times.push(started.elapsed());
assert!(generations.insert(request.generation.clone())); assert!(generations.insert(request.generation.clone()));
let endpoint = request let endpoint = request
.url .url
@@ -66,8 +73,52 @@ fn packaged_core_twenty_cold_starts_use_isolated_data_and_rotate_sessions() {
"packaged health failed on attempt {attempt}" "packaged health failed on attempt {attempt}"
); );
assert!(!response.contains(request.authorization.as_str())); 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(stream);
drop(core); drop(core);
assert!(std::net::TcpStream::connect(endpoint).is_err()); 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());
}
} }
+59 -3
View File
@@ -4,16 +4,56 @@
输出保留在当前工作树中已忽略的 .build 目录内。 输出保留在当前工作树中已忽略的 .build 目录内。
""" """
from __future__ import annotations from __future__ import annotations
import argparse
import hashlib import hashlib
import json import json
import os
from pathlib import Path from pathlib import Path
import shutil
import subprocess import subprocess
import sys import sys
import tomllib
ROOT = Path(__file__).resolve().parents[1] 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(): def main():
options = arguments()
output = ROOT / ".build" / "sidecar" output = ROOT / ".build" / "sidecar"
output.mkdir(parents=True, exist_ok=True) output.mkdir(parents=True, exist_ok=True)
subprocess.run([ subprocess.run([
@@ -36,9 +76,25 @@ def main():
if path.is_file(): if path.is_file():
with path.open("rb") as stream: with path.open("rb") as stream:
files[path.relative_to(bundle).as_posix()] = hashlib.file_digest(stream, "sha256").hexdigest() files[path.relative_to(bundle).as_posix()] = hashlib.file_digest(stream, "sha256").hexdigest()
manifest = {"protocol": 1, "product": "OpenNexus", "files": files, cargo = tomllib.loads((ROOT / "frontend" / "src-tauri" / "Cargo.toml").read_text(encoding="utf-8"))
"lock_sha256": hashlib.sha256((ROOT / "backend" / "uv.lock").read_bytes()).hexdigest()} version = cargo["package"]["version"]
(output / "manifest.json").write_text(json.dumps(manifest, sort_keys=True, separators=(",", ":")), encoding="utf-8") 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'}") print(f"Core built: {len(files)} files; manifest: {output / 'manifest.json'}")