//! Only the local main window may review Host trust or prepared installations. use super::Host; use notesagent_host::extension_store::{ExtensionStore, InstallRequest, TrustSetting}; use serde::Deserialize; use serde_json::{json, Value}; use std::{ collections::HashMap, sync::Mutex, time::{Duration, Instant}, }; use tauri::{State, WebviewWindow}; pub struct Review { setting: TrustSetting, expected: Option, fingerprint: String, expires: Instant, } #[derive(Default)] pub struct Reviews(pub Mutex>); fn main_window(window: &WebviewWindow) -> Result<(), String> { if window.label() != "main" { return Err("EXTENSION_WINDOW_DENIED".into()); } Ok(()) } fn store( host: &Host, f: impl FnOnce(&mut ExtensionStore) -> notesagent_host::workspace::Result, ) -> Result { let mut guard = host.extensions.lock().map_err(|_| "HOST_BUSY")?; f(guard.as_mut().ok_or("EXTENSIONS_NOT_READY")?).map_err(|e| e.code) } #[tauri::command] pub fn extension_trust_review( window: WebviewWindow, host: State<'_, Host>, setting: TrustSetting, ) -> Result { main_window(&window)?; trust_review(&host, setting) } fn trust_review(host: &Host, setting: TrustSetting) -> Result { let fingerprint = setting.fingerprint().map_err(|e| e.code)?; let previous = store(host, |s| { s.trust_setting(&setting.source, &setting.namespace, &setting.key_id) })?; let expected = previous .as_ref() .map(TrustSetting::fingerprint) .transpose() .map_err(|e| e.code)?; let mut reviews = host.extension_reviews.0.lock().map_err(|_| "HOST_BUSY")?; reviews.retain(|_, r| r.expires > Instant::now()); if reviews.len() >= 64 { return Err("EXTENSION_REVIEW_LIMIT".into()); } let id = uuid::Uuid::new_v4().to_string(); reviews.insert( id.clone(), Review { setting: setting.clone(), expected, fingerprint: fingerprint.clone(), expires: Instant::now() + Duration::from_secs(120), }, ); Ok(json!({"review_id":id,"fingerprint":fingerprint,"previous":previous,"proposed":setting})) } #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct Confirmation { review_id: String, fingerprint: String, } #[tauri::command] pub fn extension_trust_confirm( window: WebviewWindow, host: State<'_, Host>, request: Confirmation, ) -> Result { main_window(&window)?; trust_confirm(&host, request) } fn trust_confirm(host: &Host, request: Confirmation) -> Result { let mut reviews = host.extension_reviews.0.lock().map_err(|_| "HOST_BUSY")?; let review = reviews .get(&request.review_id) .ok_or("EXTENSION_REVIEW_EXPIRED")?; if review.expires <= Instant::now() { reviews.remove(&request.review_id); return Err("EXTENSION_REVIEW_EXPIRED".into()); } if review.fingerprint != request.fingerprint { return Err("EXTENSION_TRUST_CONFIRMATION".into()); } let revision = store(host, |s| { s.confirm_trust( &review.setting, review.expected.as_deref(), &review.fingerprint, ) })?; reviews.remove(&request.review_id); Ok(revision) } #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct Preview { root_key: String, vault_id: String, configurations: std::collections::BTreeMap, } #[tauri::command] pub async fn extension_install_preview( window: WebviewWindow, host: State<'_, Host>, request: Preview, ) -> Result { main_window(&window)?; let workspace = host.workspace.clone(); let extensions = host.extensions.clone(); tauri::async_runtime::spawn_blocking(move || { // Keep the workspace binding stable until this preview finishes. let workspace = workspace.lock().map_err(|_| "HOST_BUSY")?; if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != request.vault_id { return Err("VAULT_CHANGED".into()); } let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?; let request = InstallRequest { root_key: request.root_key, vault_id: request.vault_id, app_version: env!("CARGO_PKG_VERSION").into(), platform: std::env::consts::OS.into(), architecture: std::env::consts::ARCH.into(), configurations: request.configurations, }; let preview = store .as_mut() .ok_or("EXTENSIONS_NOT_READY")? .installation_preview(&request) .map_err(|e| e.code)?; serde_json::to_value(preview).map_err(|_| "EXTENSION_PREVIEW_INVALID".into()) }) .await .map_err(|_| "EXTENSION_PREVIEW_FAILED".to_string())? } #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct StageRequest { request_id: String, operation_id: String, source: String, release: notesagent_host::extension_package::Release, } #[tauri::command] pub async fn extension_stage( window: WebviewWindow, host: State<'_, Host>, request: StageRequest, ) -> Result { main_window(&window)?; let mut lease = host.extension_requests.claim(&request.request_id)?; let checkpoint = lease.checkpoint(); let extensions = host.extensions.clone(); tauri::async_runtime::spawn_blocking(move || { let mut store = loop { checkpoint()?; match extensions.try_lock() { Ok(guard) => break guard, Err(std::sync::TryLockError::Poisoned(_)) => return Err("HOST_BUSY".into()), Err(std::sync::TryLockError::WouldBlock) => { std::thread::sleep(Duration::from_millis(10)) } } }; let store = store.as_mut().ok_or("EXTENSIONS_NOT_READY")?; let receipt = tauri::async_runtime::block_on(lease.run(async { checkpoint()?; store .stage_online_checked( &request.operation_id, &request.source, &request.release, || { checkpoint() .map_err(|code| notesagent_host::workspace::HostError::new(&code)) }, ) .await .map_err(|e| e.code) }))?; serde_json::to_value(receipt).map_err(|_| "EXTENSION_STAGE_FAILED".into()) }) .await .map_err(|_| "EXTENSION_STAGE_FAILED".to_string())? } #[tauri::command] pub fn extension_stage_prepare( window: WebviewWindow, host: State<'_, Host>, ) -> Result { main_window(&window)?; host.extension_requests.prepare(60_000) } #[tauri::command] pub fn extension_stage_cancel( window: WebviewWindow, host: State<'_, Host>, request_id: String, ) -> Result<(), String> { main_window(&window)?; host.extension_requests.cancel(&request_id) } #[tauri::command] pub fn extension_stage_status( window: WebviewWindow, host: State<'_, Host>, operation_id: String, ) -> Result { main_window(&window)?; let receipt = store(&host, |s| s.stage_receipt(&operation_id))?; serde_json::to_value(receipt).map_err(|_| "EXTENSION_STATUS_FAILED".into()) } #[tauri::command] pub async fn extension_staged( window: WebviewWindow, host: State<'_, Host>, offset: u32, limit: u32, ) -> Result { main_window(&window)?; let extensions = host.extensions.clone(); tauri::async_runtime::spawn_blocking(move || { let store = extensions.lock().map_err(|_| "HOST_BUSY")?; let items = store .as_ref() .ok_or("EXTENSIONS_NOT_READY")? .staged(offset, limit) .map_err(|e| e.code)?; serde_json::to_value(items).map_err(|_| "EXTENSION_LIST_FAILED".into()) }) .await .map_err(|_| "EXTENSION_LIST_FAILED".to_string())? } #[tauri::command] pub fn extension_trust_confirm_group( window: WebviewWindow, host: State<'_, Host>, requests: Vec, ) -> Result, String> { main_window(&window)?; trust_confirm_group(&host, requests) } fn trust_confirm_group(host: &Host, requests: Vec) -> Result, String> { if requests.is_empty() || requests.len() > 64 { return Err("EXTENSION_TRUST_INVALID".into()); } let mut reviews = host.extension_reviews.0.lock().map_err(|_| "HOST_BUSY")?; let mut ids = std::collections::BTreeSet::new(); let mut proposals = Vec::new(); for request in &requests { if !ids.insert(&request.review_id) { return Err("EXTENSION_TRUST_INVALID".into()); } let review = reviews .get(&request.review_id) .ok_or("EXTENSION_REVIEW_EXPIRED")?; if review.expires <= Instant::now() { return Err("EXTENSION_REVIEW_EXPIRED".into()); } if review.fingerprint != request.fingerprint { return Err("EXTENSION_TRUST_CONFIRMATION".into()); } proposals.push(( review.setting.clone(), review.expected.clone(), review.fingerprint.clone(), )); } let revisions = store(host, |s| s.confirm_trust_group(&proposals))?; for request in requests { reviews.remove(&request.review_id); } Ok(revisions) } #[cfg(test)] mod tests { use super::*; #[test] fn failed_group_does_not_consume_valid_review_then_success_consumes_all() { let temp = tempfile::tempdir().unwrap(); let host = Host::default(); *host.extensions.lock().unwrap() = Some(ExtensionStore::open(temp.path()).unwrap()); let mut setting = TrustSetting { source: "https://catalog.example/".into(), source_id: "catalog".into(), namespace: "examples".into(), key_id: "one".into(), public_key: ed25519_dalek::SigningKey::from_bytes(&[7; 32]) .verifying_key() .to_bytes(), enabled: true, }; let first = trust_review(&host, setting.clone()).unwrap(); setting.key_id = "two".into(); let second = trust_review(&host, setting).unwrap(); let input = |value: &Value| Confirmation { review_id: value["review_id"].as_str().unwrap().into(), fingerprint: value["fingerprint"].as_str().unwrap().into(), }; let mut wrong = input(&second); wrong.fingerprint = "wrong".into(); assert!(trust_confirm_group(&host, vec![input(&first), wrong]).is_err()); assert_eq!(host.extension_reviews.0.lock().unwrap().len(), 2); assert!(store(&host, |s| s.trust_setting( "https://catalog.example/", "examples", "one" )) .unwrap() .is_none()); trust_confirm_group(&host, vec![input(&first), input(&second)]).unwrap(); assert!(host.extension_reviews.0.lock().unwrap().is_empty()); } #[test] fn review_nonce_expiry_consumption_and_stale_setting_are_enforced() { let temp = tempfile::tempdir().unwrap(); let host = Host::default(); *host.extensions.lock().unwrap() = Some(ExtensionStore::open(temp.path()).unwrap()); let setting = TrustSetting { source: "https://catalog.example/".into(), source_id: "catalog".into(), namespace: "examples".into(), key_id: "key".into(), public_key: ed25519_dalek::SigningKey::from_bytes(&[7; 32]) .verifying_key() .to_bytes(), enabled: true, }; let review = trust_review(&host, setting.clone()).unwrap(); let id = review["review_id"].as_str().unwrap(); let fingerprint = review["fingerprint"].as_str().unwrap(); assert!(trust_confirm( &host, Confirmation { review_id: id.into(), fingerprint: "wrong".into() } ) .is_err()); trust_confirm( &host, Confirmation { review_id: id.into(), fingerprint: fingerprint.into(), }, ) .unwrap(); assert!(trust_confirm( &host, Confirmation { review_id: id.into(), fingerprint: fingerprint.into() } ) .is_err()); let mut changed = setting.clone(); changed.enabled = false; let review = trust_review(&host, changed).unwrap(); let id = review["review_id"].as_str().unwrap(); host.extension_reviews .0 .lock() .unwrap() .get_mut(id) .unwrap() .expires = Instant::now() - Duration::from_secs(1); assert!(trust_confirm( &host, Confirmation { review_id: id.into(), fingerprint: review["fingerprint"].as_str().unwrap().into() } ) .is_err()); assert!(host.extension_reviews.0.lock().unwrap().get(id).is_none()); let mut changed = setting.clone(); changed.public_key = ed25519_dalek::SigningKey::from_bytes(&[8; 32]) .verifying_key() .to_bytes(); let review = trust_review(&host, changed).unwrap(); let mut concurrent = setting.clone(); concurrent.enabled = false; store(&host, |s| { s.confirm_trust( &concurrent, Some(&setting.fingerprint()?), &concurrent.fingerprint()?, ) }) .unwrap(); assert!(trust_confirm( &host, Confirmation { review_id: review["review_id"].as_str().unwrap().into(), fingerprint: review["fingerprint"].as_str().unwrap().into() } ) .is_err()); } }