fix: 串行化凭据所有权并响应桌面请求取消

This commit is contained in:
2026-09-08 13:16:11 +08:00
parent 8d9333d8b0
commit 0d225f308f
21 changed files with 1034 additions and 118 deletions
+13
View File
@@ -2941,6 +2941,7 @@ dependencies = [
"tauri",
"tauri-build",
"tempfile",
"tokio",
"uuid",
"zeroize",
]
@@ -5090,9 +5091,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "tokio-rustls"
version = "0.26.5"
+2 -1
View File
@@ -14,7 +14,7 @@ required-features = ["desktop"]
[features]
default = []
desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest", "dep:base64"]
desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest", "dep:base64", "dep:tokio"]
[dependencies]
serde = { version = "1", features = ["derive"] }
@@ -28,6 +28,7 @@ tauri = { version = "2", optional = true, features = ["tray-icon"] }
rfd = { version = "0.15", optional = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
base64 = { version = "0.22", optional = true }
tokio = { version = "1", features = ["rt", "sync", "time", "macros"], optional = true }
hmac = { version = "0.12", default-features = false }
rand = { version = "0.8", default-features = false, features = ["getrandom"] }
zeroize = { version = "1", default-features = false, features = ["alloc"] }
+2
View File
@@ -22,6 +22,8 @@ fn main() {
"credentials_change_password",
"credentials_import",
"core_request",
"core_request_prepare",
"core_request_cancel",
"core_stream",
"core_stream_cancel",
"editor_capabilities",
@@ -14,6 +14,8 @@
"allow-credentials-change-password",
"allow-credentials-import",
"allow-core-request",
"allow-core-request-prepare",
"allow-core-request-cancel",
"allow-core-stream",
"allow-core-stream-cancel",
"allow-workspace-choose",
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-core-request-cancel"
description = "Enables the core_request_cancel command without any pre-configured scope."
commands.allow = ["core_request_cancel"]
[[permission]]
identifier = "deny-core-request-cancel"
description = "Denies the core_request_cancel command without any pre-configured scope."
commands.deny = ["core_request_cancel"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-core-request-prepare"
description = "Enables the core_request_prepare command without any pre-configured scope."
commands.allow = ["core_request_prepare"]
[[permission]]
identifier = "deny-core-request-prepare"
description = "Denies the core_request_prepare command without any pre-configured scope."
commands.deny = ["core_request_prepare"]
+46
View File
@@ -201,6 +201,9 @@ impl Unlocked {
pub struct CredentialBroker {
path: PathBuf,
unlocked: Option<Unlocked>,
// Separate stable inode: snapshots are atomically replaced, so locking the
// snapshot itself would not protect the next writer after replacement.
ownership: Option<fs::File>,
}
impl CredentialBroker {
@@ -479,6 +482,7 @@ impl CredentialBroker {
Self {
path,
unlocked: None,
ownership: None,
}
}
pub fn is_locked(&self) -> bool {
@@ -486,9 +490,50 @@ impl CredentialBroker {
}
pub fn lock(&mut self) {
self.unlocked.take();
self.ownership.take();
}
pub fn unlock(&mut self, password: Zeroizing<Vec<u8>>) -> Result<()> {
self.lock();
use fs2::FileExt;
let parent = self.path.parent().ok_or("CREDENTIAL_PATH_INVALID")?;
fs::create_dir_all(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?;
let mut lock_name = self
.path
.file_name()
.ok_or("CREDENTIAL_PATH_INVALID")?
.to_os_string();
lock_name.push(".lock");
let lock_path = parent.join(lock_name);
if let Ok(metadata) = fs::symlink_metadata(&lock_path) {
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err("CREDENTIAL_PATH_INVALID".into());
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
if metadata.file_attributes() & 0x400 != 0 {
return Err("CREDENTIAL_PATH_INVALID".into());
}
}
}
let mut options = fs::OpenOptions::new();
options.read(true).write(true).create(true).truncate(false);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.share_mode(0x1 | 0x2); // Do not allow replacing the held lock file.
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let ownership = options
.open(&lock_path)
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
ownership
.try_lock_exclusive()
.map_err(|_| "CREDENTIALS_BUSY")?;
let session = if self.path.exists() {
let metadata = fs::symlink_metadata(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
if !metadata.is_file() || metadata.len() > MAX_FILE {
@@ -530,6 +575,7 @@ impl CredentialBroker {
session
};
self.unlocked = Some(session);
self.ownership = Some(ownership);
Ok(())
}
pub fn list(&self) -> Result<Vec<CredentialId>> {
+2
View File
@@ -3,5 +3,7 @@
pub mod core;
pub mod credentials;
pub mod recent;
#[cfg(feature = "desktop")]
pub mod request_lifecycle;
mod runtime_compat;
pub mod workspace;
+152 -100
View File
@@ -6,6 +6,7 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use notesagent_host::core::CoreSupervisor;
use notesagent_host::credentials::CredentialBroker;
use notesagent_host::recent::{RecentVault, RecentVaultStore};
use notesagent_host::request_lifecycle::Requests;
use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace};
use std::collections::HashMap;
use std::path::Path;
@@ -16,6 +17,7 @@ use zeroize::Zeroizing;
#[derive(Default)]
struct Host {
requests: Requests,
workspace: Mutex<Option<Workspace>>,
recent: Mutex<Option<RecentVaultStore>>,
core: Arc<Mutex<Option<CoreSupervisor>>>,
@@ -87,6 +89,14 @@ fn core_url(path: &str) -> Result<String, String> {
mod core_proxy_tests {
use super::{core_url, is_json_content_type};
#[test]
fn request_dto_accepts_camel_case_and_rejects_unowned_headers() {
let mut payload = serde_json::json!({"requestId":"fixture-reservation","method":"POST","path":"/api/tasks","body":{"title":"fixture"},"contentType":"application/json"});
assert!(serde_json::from_value::<super::CoreRequest>(payload.clone()).is_ok());
payload["authorization"] = serde_json::json!("must-not-be-forwarded");
assert!(serde_json::from_value::<super::CoreRequest>(payload).is_err());
}
#[test]
fn only_allows_expected_loopback_paths() {
assert_eq!(core_url("/health").unwrap(), "http://127.0.0.1:8000/health");
@@ -118,115 +128,155 @@ mod core_proxy_tests {
/// Authenticated process-local transport; session headers are owned by Rust.
#[tauri::command]
async fn core_request(
fn core_request_prepare(host: State<'_, Host>, timeout_ms: u64) -> Result<String, String> {
host.requests.prepare(timeout_ms)
}
#[tauri::command]
fn core_request_cancel(host: State<'_, Host>, request_id: String) -> Result<(), String> {
host.requests.cancel(&request_id)
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CoreRequest {
request_id: String,
method: String,
path: String,
body: Option<serde_json::Value>,
body_base64: Option<String>,
content_type: Option<String>,
idempotency_key: Option<String>,
host: State<'_, Host>,
) -> Result<CoreResponse, String> {
let core = host.core.clone();
let core_path = path.clone();
let session = tauri::async_runtime::spawn_blocking(move || {
core.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("CORE_UNAVAILABLE")?
.request_session(&core_path)
})
.await
.map_err(|_| "CORE_UNAVAILABLE")??;
let method =
reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?;
if !matches!(
}
#[tauri::command]
async fn core_request(request: CoreRequest, host: State<'_, Host>) -> Result<CoreResponse, String> {
let CoreRequest {
request_id,
method,
reqwest::Method::GET
| reqwest::Method::POST
| reqwest::Method::PUT
| reqwest::Method::PATCH
| reqwest::Method::DELETE
) {
return Err("CORE_METHOD_DENIED".into());
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build()
.map_err(|_| "CORE_CLIENT_ERROR")?;
let mut request = client
.request(method, &session.url)
.header(
reqwest::header::AUTHORIZATION,
session.authorization.as_str(),
)
.header("X-Core-Generation", &session.generation);
if let Some(value) = body {
request = request.json(&value);
}
if let Some(encoded) = body_base64 {
if encoded.len() > MAX_CORE_RESPONSE_BYTES * 4 / 3 + 4 {
return Err("CORE_REQUEST_TOO_LARGE".into());
}
let bytes = BASE64_STANDARD
.decode(encoded)
.map_err(|_| "CORE_BODY_INVALID")?;
if bytes.len() > MAX_CORE_RESPONSE_BYTES {
return Err("CORE_REQUEST_TOO_LARGE".into());
}
let content_type = content_type
.as_deref()
.unwrap_or("application/octet-stream");
if !matches!(content_type, "application/octet-stream" | "application/zip") {
return Err("CORE_CONTENT_TYPE_DENIED".into());
}
request = request
.header(reqwest::header::CONTENT_TYPE, content_type)
.body(bytes);
}
if let Some(key) = idempotency_key {
if key.len() > 128 || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err("CORE_HEADER_INVALID".into());
}
request = request.header("Idempotency-Key", key);
}
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
let status = response.status().as_u16();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_owned();
if response
.content_length()
.is_some_and(|length| length > MAX_CORE_RESPONSE_BYTES as u64)
{
return Err("CORE_RESPONSE_TOO_LARGE".into());
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| "CORE_RESPONSE_ERROR")? {
if bytes.len().saturating_add(chunk.len()) > MAX_CORE_RESPONSE_BYTES {
return Err("CORE_RESPONSE_TOO_LARGE".into());
}
bytes.extend_from_slice(&chunk);
}
let (body, body_base64) = if is_json_content_type(&content_type) {
(
String::from_utf8(bytes.to_vec()).map_err(|_| "CORE_RESPONSE_ERROR")?,
None,
)
} else {
(String::new(), Some(BASE64_STANDARD.encode(&bytes)))
};
Ok(CoreResponse {
status,
content_type,
path,
body,
body_base64,
})
content_type,
idempotency_key,
} = request;
let mut lease = host.requests.claim(&request_id)?;
let checkpoint = lease.checkpoint();
lease
.run(async {
if body.is_some() && body_base64.is_some() {
return Err("CORE_BODY_INVALID".into());
}
if body
.as_ref()
.is_some_and(|value| value.to_string().len() > MAX_CORE_RESPONSE_BYTES)
{
return Err("CORE_REQUEST_TOO_LARGE".into());
}
let core = host.core.clone();
let core_path = path.clone();
let session = tauri::async_runtime::spawn_blocking(move || {
core.lock()
.map_err(|_| "HOST_BUSY")?
.as_mut()
.ok_or("CORE_UNAVAILABLE")?
.request_session(&core_path)
})
.await
.map_err(|_| "CORE_UNAVAILABLE")??;
let method =
reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?;
if !matches!(
method,
reqwest::Method::GET
| reqwest::Method::POST
| reqwest::Method::PUT
| reqwest::Method::PATCH
| reqwest::Method::DELETE
) {
return Err("CORE_METHOD_DENIED".into());
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(600))
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.build()
.map_err(|_| "CORE_CLIENT_ERROR")?;
let mut request = client
.request(method, &session.url)
.header(
reqwest::header::AUTHORIZATION,
session.authorization.as_str(),
)
.header("X-Core-Generation", &session.generation)
.header("X-Request-Id", &request_id);
if let Some(value) = body {
request = request.json(&value);
}
if let Some(encoded) = body_base64 {
if encoded.len() > MAX_CORE_RESPONSE_BYTES * 4 / 3 + 4 {
return Err("CORE_REQUEST_TOO_LARGE".into());
}
let bytes = BASE64_STANDARD
.decode(encoded)
.map_err(|_| "CORE_BODY_INVALID")?;
if bytes.len() > MAX_CORE_RESPONSE_BYTES {
return Err("CORE_REQUEST_TOO_LARGE".into());
}
let content_type = content_type
.as_deref()
.unwrap_or("application/octet-stream");
if !matches!(content_type, "application/octet-stream" | "application/zip") {
return Err("CORE_CONTENT_TYPE_DENIED".into());
}
request = request
.header(reqwest::header::CONTENT_TYPE, content_type)
.body(bytes);
}
if let Some(key) = idempotency_key {
if key.len() > 128 || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err("CORE_HEADER_INVALID".into());
}
request = request.header("Idempotency-Key", key);
}
checkpoint()?;
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
let status = response.status().as_u16();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_owned();
if response
.content_length()
.is_some_and(|length| length > MAX_CORE_RESPONSE_BYTES as u64)
{
return Err("CORE_RESPONSE_TOO_LARGE".into());
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| "CORE_RESPONSE_ERROR")? {
if bytes.len().saturating_add(chunk.len()) > MAX_CORE_RESPONSE_BYTES {
return Err("CORE_RESPONSE_TOO_LARGE".into());
}
bytes.extend_from_slice(&chunk);
}
let (body, body_base64) = if is_json_content_type(&content_type) {
(
String::from_utf8(bytes.to_vec()).map_err(|_| "CORE_RESPONSE_ERROR")?,
None,
)
} else {
(String::new(), Some(BASE64_STANDARD.encode(&bytes)))
};
Ok(CoreResponse {
status,
content_type,
body,
body_base64,
})
})
.await
}
#[tauri::command]
@@ -627,6 +677,8 @@ fn main() {
credentials_change_password,
credentials_import,
core_request,
core_request_prepare,
core_request_cancel,
core_stream,
core_stream_cancel,
editor_capabilities,
+236
View File
@@ -0,0 +1,236 @@
//! Reserve before dispatch so cancellation cannot race a delayed IPC invocation.
use std::{
collections::HashMap,
future::Future,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tokio::sync::watch;
struct Entry {
deadline: Instant,
claimed: bool,
cancel: watch::Sender<bool>,
}
#[derive(Default, Clone)]
pub struct Requests {
entries: Arc<Mutex<HashMap<String, Entry>>>,
}
pub struct Lease {
id: String,
owner: Requests,
deadline: Instant,
cancel: watch::Receiver<bool>,
}
impl Requests {
pub fn prepare(&self, timeout_ms: u64) -> Result<String, String> {
if !(1..=600_000).contains(&timeout_ms) {
return Err("CORE_TIMEOUT_INVALID".into());
}
let mut entries = self.entries.lock().map_err(|_| "HOST_BUSY")?;
entries.retain(|_, entry| entry.claimed || entry.deadline > Instant::now());
if entries.len() >= 64 {
return Err("CORE_REQUEST_LIMIT".into());
}
let id = uuid::Uuid::new_v4().to_string();
let (cancel, _) = watch::channel(false);
entries.insert(
id.clone(),
Entry {
deadline: Instant::now() + Duration::from_millis(timeout_ms),
claimed: false,
cancel,
},
);
Ok(id)
}
pub fn claim(&self, id: &str) -> Result<Lease, String> {
let mut entries = self.entries.lock().map_err(|_| "HOST_BUSY")?;
let entry = entries.get_mut(id).ok_or("CORE_REQUEST_NOT_PREPARED")?;
if entry.claimed {
return Err("CORE_REQUEST_ALREADY_STARTED".into());
}
if entry.deadline <= Instant::now() {
entries.remove(id);
return Err("REQUEST_TIMEOUT".into());
}
entry.claimed = true;
Ok(Lease {
id: id.into(),
owner: self.clone(),
deadline: entry.deadline,
cancel: entry.cancel.subscribe(),
})
}
pub fn cancel(&self, id: &str) -> Result<(), String> {
if let Some(entry) = self.entries.lock().map_err(|_| "HOST_BUSY")?.remove(id) {
entry.cancel.send_replace(true);
}
Ok(())
}
}
impl Lease {
/// Recheck after synchronous encoding/validation, immediately before network IO.
pub fn checkpoint(&self) -> impl Fn() -> Result<(), String> + Send + 'static {
let cancel = self.cancel.clone();
let deadline = self.deadline;
move || {
if *cancel.borrow() {
return Err("REQUEST_CANCELLED".into());
}
if deadline <= Instant::now() {
return Err("REQUEST_TIMEOUT".into());
}
Ok(())
}
}
pub async fn run<T>(
&mut self,
operation: impl Future<Output = Result<T, String>>,
) -> Result<T, String> {
// Check current state before polling an operation with possible side effects.
if *self.cancel.borrow() {
return Err("REQUEST_CANCELLED".into());
}
if self.deadline <= Instant::now() {
return Err("REQUEST_TIMEOUT".into());
}
tokio::select! {
biased;
_ = self.cancel.changed() => Err("REQUEST_CANCELLED".into()),
_ = tokio::time::sleep_until(self.deadline.into()) => Err("REQUEST_TIMEOUT".into()),
value = operation => value,
}
}
}
impl Drop for Lease {
fn drop(&mut self) {
if let Ok(mut entries) = self.owner.entries.lock() {
entries.remove(&self.id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn cancellation_before_dispatch_and_replay_never_run_work() {
let requests = Requests::default();
let id = requests.prepare(1000).unwrap();
requests.cancel(&id).unwrap();
assert!(requests.claim(&id).is_err());
let id = requests.prepare(1000).unwrap();
let mut lease = requests.claim(&id).unwrap();
assert!(requests.claim(&id).is_err());
requests.cancel(&id).unwrap();
let result: Result<(), String> = lease
.run(async { panic!("cancelled work was polled") })
.await;
assert_eq!(result.unwrap_err(), "REQUEST_CANCELLED");
}
#[tokio::test]
async fn timeout_and_cancel_drop_inflight_work_and_release_capacity() {
let requests = Requests::default();
let id = requests.prepare(10).unwrap();
let mut lease = requests.claim(&id).unwrap();
assert_eq!(
lease
.run(std::future::pending::<Result<(), String>>())
.await
.unwrap_err(),
"REQUEST_TIMEOUT"
);
drop(lease);
assert!(requests.entries.lock().unwrap().is_empty());
let id = requests.prepare(1000).unwrap();
let mut lease = requests.claim(&id).unwrap();
let (result, ()) = tokio::join!(
lease.run(std::future::pending::<Result<(), String>>()),
async {
tokio::task::yield_now().await;
requests.cancel(&id).unwrap();
}
);
assert_eq!(result.unwrap_err(), "REQUEST_CANCELLED");
drop(lease);
assert!(requests.entries.lock().unwrap().is_empty());
}
#[test]
fn capacity_and_expired_reservations_are_bounded() {
let requests = Requests::default();
for _ in 0..64 {
requests.prepare(1000).unwrap();
}
assert_eq!(requests.prepare(1000).unwrap_err(), "CORE_REQUEST_LIMIT");
for entry in requests.entries.lock().unwrap().values_mut() {
entry.deadline = Instant::now();
}
assert!(requests.prepare(1000).is_ok());
}
#[tokio::test]
async fn cancelling_a_real_response_closes_its_socket() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let endpoint = listener.local_addr().unwrap();
let (started, received) = tokio::sync::oneshot::channel();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
let mut byte = [0];
socket.read_exact(&mut byte).unwrap();
request.push(byte[0]);
}
socket
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\nConnection: close\r\n\r\nx",
)
.unwrap();
started.send(()).unwrap();
match socket.read(&mut [0]) {
Ok(0) => true,
Err(error) => matches!(
error.kind(),
std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::ConnectionAborted
),
_ => false,
}
});
let requests = Requests::default();
let id = requests.prepare(10000).unwrap();
let mut lease = requests.claim(&id).unwrap();
let operation = async {
let mut response = reqwest::Client::builder()
.no_proxy()
.build()
.unwrap()
.get(format!("http://{endpoint}/"))
.send()
.await
.map_err(|_| "HTTP_FAILURE".to_string())?;
while response
.chunk()
.await
.map_err(|_| "BODY_FAILURE".to_string())?
.is_some()
{}
Ok(())
};
let (result, ()) = tokio::join!(lease.run(operation), async {
received.await.unwrap();
requests.cancel(&id).unwrap();
});
assert_eq!(result.unwrap_err(), "REQUEST_CANCELLED");
assert!(
tokio::task::spawn_blocking(move || server.join().unwrap())
.await
.unwrap(),
"HTTP socket was not closed on cancellation"
);
}
}
@@ -0,0 +1,114 @@
use notesagent_host::credentials::{CredentialBroker, CredentialId, Scope};
use zeroize::Zeroizing;
fn password() -> Zeroizing<Vec<u8>> {
Zeroizing::new(b"isolated-ownership-fixture".to_vec())
}
#[test]
fn stale_writer_is_rejected_then_handoff_preserves_both_commits() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("vault");
let a = CredentialId::legacy("first");
let b = CredentialId::legacy("second");
let mut one = CredentialBroker::new(path.clone());
one.unlock(password()).unwrap();
let mut two = CredentialBroker::new(path.clone());
assert_eq!(two.unlock(password()).unwrap_err(), "CREDENTIALS_BUSY");
assert!(two.is_locked());
one.put(&a, Zeroizing::new(b"fixture-a".to_vec())).unwrap();
assert_eq!(
two.put(&b, Zeroizing::new(b"fixture-b".to_vec()))
.unwrap_err(),
"CREDENTIALS_LOCKED"
);
one.lock();
two.unlock(password()).unwrap();
assert!(two.resolve(&Scope::Provider, &a).unwrap().is_some());
two.put(&b, Zeroizing::new(b"fixture-b".to_vec())).unwrap();
two.lock();
// A failed password attempt must release its ownership too.
assert!(one
.unlock(Zeroizing::new(b"wrong-fixture-password".to_vec()))
.is_err());
two.unlock(password()).unwrap();
assert!(two.resolve(&Scope::Provider, &a).unwrap().is_some());
assert!(two.resolve(&Scope::Provider, &b).unwrap().is_some());
}
#[test]
fn process_lock_is_exclusive_and_released_on_crash() {
use std::io::{BufRead, Read, Write};
use std::process::{Command, Stdio};
if let Ok(role) = std::env::var("OPENNEXUS_OWNERSHIP_TEST_ROLE") {
let path =
std::path::PathBuf::from(std::env::var_os("OPENNEXUS_OWNERSHIP_TEST_PATH").unwrap());
let mut broker = CredentialBroker::new(path);
if role == "blocked" {
assert_eq!(broker.unlock(password()).unwrap_err(), "CREDENTIALS_BUSY");
return;
}
broker.unlock(password()).unwrap();
broker
.put(
&CredentialId::legacy("child"),
Zeroizing::new(b"child-fixture".to_vec()),
)
.unwrap();
println!("OWNERSHIP_READY");
std::io::stdout().flush().unwrap();
let _ = std::io::stdin().read_exact(&mut [0]);
return;
}
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("vault");
let mut broker = CredentialBroker::new(path.clone());
broker.unlock(password()).unwrap();
let command = |role: &str| {
let mut c = Command::new(std::env::current_exe().unwrap());
c.args([
"--exact",
"process_lock_is_exclusive_and_released_on_crash",
"--nocapture",
])
.env("OPENNEXUS_OWNERSHIP_TEST_ROLE", role)
.env("OPENNEXUS_OWNERSHIP_TEST_PATH", &path);
c
};
assert!(command("blocked")
.stdout(Stdio::null())
.status()
.unwrap()
.success());
broker.lock();
let mut child = command("owner")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
let output = child.stdout.take().unwrap();
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for line in std::io::BufReader::new(output)
.lines()
.map_while(Result::ok)
{
if line == "OWNERSHIP_READY" {
let _ = sender.send(());
break;
}
}
});
let ready = receiver
.recv_timeout(std::time::Duration::from_secs(30))
.is_ok();
if ready {
assert_eq!(broker.unlock(password()).unwrap_err(), "CREDENTIALS_BUSY");
}
child.kill().unwrap();
child.wait().unwrap();
assert!(ready, "child never acquired ownership");
broker.unlock(password()).unwrap();
assert!(broker
.resolve(&Scope::Provider, &CredentialId::legacy("child"))
.unwrap()
.is_some());
}
@@ -63,10 +63,10 @@ describe('EditorPane file switching', () => {
wrapper = mount(EditorPane, { attachTo: document.body })
await nextTick()
await vi.waitFor(() => expect(wrapper!.find('.cm-content').exists()).toBe(true))
await vi.waitFor(() => expect(wrapper!.find('.cm-content').exists()).toBe(true), { timeout: 10000 })
const textarea = wrapper.get('.cm-content')
expect(textarea.attributes('spellcheck')).toBe('true')
expect(textarea.attributes('lang')).toBe('en')
expect(textarea.attributes('aria-label')).toBe('Markdown source editor')
})
}, 15000) // Lazy source-editor module transforms need the same cold-start budget.
})
@@ -8,6 +8,12 @@ const busy = ref(false)
const password = ref('')
const confirmation = ref('')
const message = ref('')
function failureMessage(error: unknown, fallback: string) {
const code = error instanceof Error ? error.message : fallback
return code === 'CREDENTIALS_BUSY'
? t('保险库正在使用中,请等待当前操作完成,或在其他 OpenNexus 实例中锁定后重试。', 'The vault is busy. Wait for the current operation, or lock it in the other OpenNexus instance before retrying.')
: code
}
async function refresh() {
const state = await hostInvoke<{ locked: boolean }>('credentials_status')
locked.value = state.locked
@@ -17,7 +23,7 @@ async function importLegacy() {
try {
const count = await hostInvoke<number | null>('credentials_import')
if (count !== null) message.value = t(`已迁移并验证 ${count} 条凭据;旧文件仍保留。`, `Imported and verified ${count} credentials. Legacy files are retained.`)
} catch (error) { message.value = error instanceof Error ? error.message : 'MIGRATION_FAILED' }
} catch (error) { message.value = failureMessage(error, 'MIGRATION_FAILED') }
finally { busy.value = false }
}
async function act(action: 'unlock' | 'lock' | 'change_password') {
@@ -33,7 +39,7 @@ async function act(action: 'unlock' | 'lock' | 'change_password') {
await hostInvoke(`credentials_${action}`, action === 'lock' ? undefined : { password: value })
await refresh()
message.value = action === 'change_password' ? t('口令已更新。', 'Password updated.') : ''
} catch (error) { message.value = error instanceof Error ? error.message : 'CREDENTIAL_STORE_FAILED' }
} catch (error) { message.value = failureMessage(error, 'CREDENTIAL_STORE_FAILED') }
finally { busy.value = false }
}
onMounted(() => refresh().catch(error => { message.value = String(error) }))
@@ -0,0 +1,49 @@
// @vitest-environment happy-dom
import { expect, it, vi, beforeEach, afterEach } from 'vitest'
const { hostInvoke } = vi.hoisted(() => ({ hostInvoke: vi.fn() }))
vi.mock('./platform/desktop', () => ({ isDesktop: () => true, hostInvoke }))
import apiClient from './apiClient'
beforeEach(() => {
hostInvoke.mockReset()
hostInvoke.mockImplementation(command => command === 'core_request_prepare' ? Promise.resolve('reservation') :
command === 'core_request_cancel' ? Promise.resolve() : new Promise(() => {}))
})
afterEach(() => vi.useRealTimers())
it('never invokes Host for a pre-aborted mutation', async () => {
const abort = new AbortController(); abort.abort()
await expect(apiClient.post('/api/tasks', {}, { signal: abort.signal })).rejects.toMatchObject({ code: 'REQUEST_CANCELLED', details: { outcome: 'not_sent' } })
expect(hostInvoke).not.toHaveBeenCalled()
})
it('rejects on deadline and cancels the native work rather than awaiting its response', async () => {
vi.useFakeTimers()
const result = expect(apiClient.post('/api/tasks', {}, { timeoutMs: 10 })).rejects.toMatchObject({ code: 'REQUEST_TIMEOUT', details: { outcome: 'unknown' } })
await vi.advanceTimersByTimeAsync(10); await result
expect(hostInvoke).toHaveBeenCalledWith('core_request_cancel', { requestId: 'reservation' })
expect(vi.getTimerCount()).toBe(0)
})
it('cancels a late reservation without dispatching after the caller has aborted', async () => {
let reserve!: (id: string) => void
hostInvoke.mockImplementationOnce(() => new Promise(resolve => { reserve = resolve }))
const abort = new AbortController()
const result = expect(apiClient.post('/api/tasks', {}, { signal: abort.signal })).rejects.toMatchObject({ code: 'REQUEST_CANCELLED' })
abort.abort(); await result; reserve('late-reservation')
await vi.waitFor(() => expect(hostInvoke).toHaveBeenCalledWith('core_request_cancel', { requestId: 'late-reservation' }))
expect(hostInvoke.mock.calls.some(([command]) => command === 'core_request')).toBe(false)
})
it('propagates native deadline errors even when the browser timer has not fired', async () => {
hostInvoke.mockImplementation(command => command === 'core_request_prepare' ? Promise.resolve('reservation') : command === 'core_request' ? Promise.reject({code:'REQUEST_TIMEOUT'}) : Promise.resolve())
await expect(apiClient.get('/api/status')).rejects.toMatchObject({code:'REQUEST_TIMEOUT'})
})
it('dispatches the frozen request envelope and clears its deadline after success', async () => {
vi.useFakeTimers()
hostInvoke.mockImplementation(command => Promise.resolve(command === 'core_request_prepare' ? 'reservation' : {status:200,content_type:'application/json',body:'{"ok":true}'}))
expect(await apiClient.post('/api/tasks', {title:'fixture'}, {token:'not-forwarded'})).toEqual({ok:true})
expect(hostInvoke).toHaveBeenCalledWith('core_request', {request: expect.objectContaining({requestId:'reservation',method:'POST',path:'/api/tasks',body:{title:'fixture'}})})
expect(JSON.stringify(hostInvoke.mock.calls)).not.toContain('not-forwarded')
expect(vi.getTimerCount()).toBe(0)
})
it('retains binary bytes and media type through the cancellable transport', async () => {
hostInvoke.mockImplementation(command => Promise.resolve(command === 'core_request_prepare' ? 'reservation' : {status:200,content_type:'application/json',body:'{}'}))
await apiClient.postBinary('/api/packages',new Blob([new Uint8Array([0,255,128])]))
expect(hostInvoke).toHaveBeenCalledWith('core_request', {request: expect.objectContaining({requestId:'reservation',bodyBase64:'AP+A',contentType:'application/zip'})})
})
@@ -6,7 +6,10 @@ vi.mock('./platform/desktop', () => ({ isDesktop: () => true, hostInvoke }))
import apiClient from './apiClient'
beforeEach(() => hostInvoke.mockReset())
beforeEach(() => {
hostInvoke.mockReset()
hostInvoke.mockResolvedValueOnce('test-reservation')
})
it('restores binary desktop responses as browser-compatible response objects', async () => {
hostInvoke.mockResolvedValue({
+21 -9
View File
@@ -1,5 +1,6 @@
import type { ApiError, ErrorResponse } from '@/contracts'
import { hostInvoke, isDesktop } from './platform/desktop'
import { isDesktop } from './platform/desktop'
import { coreRequest, type RequestProgress } from './platform/coreRequest'
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
@@ -50,12 +51,18 @@ export class ApiErrorClass extends Error {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { params, token, headers, timeoutMs, ...rest } = options
const controller = timeoutMs ? new AbortController() : null
const desktop = isDesktop()
const deadlineMs = timeoutMs ?? (desktop ? 30_000 : undefined)
if (deadlineMs !== undefined && (!Number.isFinite(deadlineMs) || deadlineMs <= 0 || (desktop && deadlineMs > 600_000))) {
throw new ApiErrorClass('CORE_TIMEOUT_INVALID', '请求超时设置无效')
}
const controller = new AbortController()
const progress: RequestProgress = { issued: false }
let timedOut = false
const abort = () => controller?.abort()
const abort = () => controller.abort()
if (rest.signal?.aborted) abort()
rest.signal?.addEventListener('abort', abort, { once: true })
const timer = timeoutMs ? setTimeout(() => { timedOut = true; controller?.abort() }, timeoutMs) : undefined
const timer = deadlineMs ? setTimeout(() => { timedOut = true; controller.abort() }, deadlineMs) : undefined
let url = resolveApiUrl(path)
@@ -81,7 +88,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
reqHeaders['X-Request-Id'] = reqId
try {
if (isDesktop()) {
controller.signal.throwIfAborted()
if (desktop) {
const parsed = new URL(url, 'http://localhost')
let bodyBase64: string | undefined
if (rest.body instanceof Blob) {
@@ -91,14 +99,14 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
for (let offset = 0; offset < bytes.length; offset += 16384) parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 16384)))
bodyBase64 = btoa(parts.join(''))
}
const response = await hostInvoke<DesktopCoreResponse>('core_request', {
const response = await coreRequest<DesktopCoreResponse>({
method: rest.method ?? 'GET',
path: `${parsed.pathname}${parsed.search}`,
body: typeof rest.body === 'string' ? JSON.parse(rest.body) : undefined,
bodyBase64,
contentType: new Headers(reqHeaders).get('Content-Type'),
idempotencyKey: new Headers(reqHeaders).get('Idempotency-Key') ?? undefined,
})
}, controller.signal, Math.ceil(deadlineMs!), progress)
if (response.status >= 200 && response.status < 300) {
if (response.status === 204) return undefined as T
return (response.content_type.includes('application/json')
@@ -111,7 +119,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
}
const resp = await fetch(url, {
...rest,
signal: controller?.signal ?? rest.signal,
signal: controller.signal,
headers: reqHeaders,
})
@@ -136,7 +144,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
throw new ApiErrorClass(code, message, details)
} catch (e) {
if (timedOut) throw new ApiErrorClass('REQUEST_TIMEOUT', '请求超时,请检查后端状态后重试。')
const nativeCode = (e as { code?: string })?.code
const uncertain = desktop && progress.issued && !['GET', 'HEAD'].includes(rest.method ?? 'GET')
const details = desktop ? { request_id: progress.requestId, outcome: progress.issued ? 'unknown' : 'not_sent' } : undefined
if (timedOut || nativeCode === 'REQUEST_TIMEOUT') throw new ApiErrorClass('REQUEST_TIMEOUT', uncertain ? '请求超时,变更可能已提交,请先检查结果。' : '请求超时,请检查连接。', details)
if (controller.signal.aborted || nativeCode === 'REQUEST_CANCELLED') throw new ApiErrorClass('REQUEST_CANCELLED', uncertain ? '请求已取消,变更可能已提交,请先检查结果。' : '请求已取消。', details)
if (e instanceof ApiErrorClass) throw e
throw new ApiErrorClass('NETWORK_ERROR', (e as Error).message || 'Network error')
} finally {
@@ -0,0 +1,39 @@
import { hostInvoke } from './desktop'
export interface RequestProgress { issued: boolean; requestId?: string }
/** A reservation makes cancel-before-dispatch definitive even across IPC ordering. */
export function coreRequest<T>(args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number, progress: RequestProgress): Promise<T> {
return new Promise((resolve, reject) => {
let settled = false
let requestId: string | undefined
const cancel = () => {
if (requestId) void hostInvoke('core_request_cancel', { requestId }).catch(() => {})
}
const abort = () => {
if (settled) return
settled = true
signal.removeEventListener('abort', abort)
cancel()
reject(new DOMException('Request aborted', 'AbortError'))
}
if (signal.aborted) { abort(); return }
signal.addEventListener('abort', abort, { once: true })
void (async () => {
try {
requestId = await hostInvoke<string>('core_request_prepare', { timeoutMs })
progress.requestId = requestId
if (settled || signal.aborted) { cancel(); return }
progress.issued = true
const result = await hostInvoke<T>('core_request', { request: { ...args, requestId } })
if (!settled) { settled = true; resolve(result) }
} catch (error) {
if (!settled) { settled = true; reject(error) }
} finally {
signal.removeEventListener('abort', abort)
// Also discard a reservation if dispatch failed before Rust claimed it.
cancel()
}
})()
})
}