Skip to main content

victauri_plugin/mcp/
server.rs

1use std::sync::Arc;
2use std::sync::atomic::Ordering;
3
4use axum::extract::DefaultBodyLimit;
5use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
6use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
7use tauri::Runtime;
8use tower::limit::ConcurrencyLimitLayer;
9
10use crate::VictauriState;
11use crate::bridge::WebviewBridge;
12
13use super::{MAX_PENDING_EVALS, VictauriMcpHandler};
14
15const DEFAULT_WEBVIEW_LABEL: &str = "main";
16
17// ── Server startup ───────────────────────────────────────────────────────────
18
19/// Build an Axum router for the MCP server with default options (no auth token).
20pub fn build_app(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> axum::Router {
21    build_app_with_options(state, bridge, None)
22}
23
24/// Normalize an auth token: an empty/whitespace-only `Some("")` collapses to `None`
25/// (no auth) with a loud warning (audit B2).
26///
27/// A `Some("")` token would otherwise enable the auth middleware AND report
28/// `auth_required: true` while accepting an empty Bearer credential — an
29/// auth-enabled-but-bypassable state. Applied uniformly to both the request gate
30/// and the discovery-file token so they can never disagree.
31#[must_use]
32fn normalize_auth_token(auth_token: Option<String>) -> Option<String> {
33    match auth_token {
34        Some(t) if t.trim().is_empty() => {
35            tracing::warn!(
36                "Victauri: configured auth token is empty/whitespace — treating as NO auth. \
37                 Set a non-empty VICTAURI_AUTH_TOKEN / auth_token(), or use auth_disabled() \
38                 to intentionally run without authentication."
39            );
40            None
41        }
42        other => other,
43    }
44}
45
46/// Build an Axum router for the MCP server with an optional auth token and rate limiter.
47pub fn build_app_with_options(
48    state: Arc<VictauriState>,
49    bridge: Arc<dyn WebviewBridge>,
50    auth_token: Option<String>,
51) -> axum::Router {
52    build_app_full(state, bridge, auth_token, None)
53}
54
55/// Build an Axum router with full control over auth token and rate limiter.
56///
57/// The MCP transport runs **stateless** (the default since the 422 stale-session fix). For the
58/// stateful transport (sessions + server-initiated SSE push, required by MCP resource
59/// *subscriptions*) use [`build_app_stateful`].
60pub fn build_app_full(
61    state: Arc<VictauriState>,
62    bridge: Arc<dyn WebviewBridge>,
63    auth_token: Option<String>,
64    rate_limiter: Option<Arc<crate::auth::RateLimiterState>>,
65) -> axum::Router {
66    build_app_full_inner(state, bridge, auth_token, rate_limiter, false)
67}
68
69/// Build an Axum router whose MCP transport runs in **stateful** mode (sessions + a long-lived SSE
70/// channel), for clients that require the session-based Streamable-HTTP protocol.
71///
72/// The production default ([`build_app_full`]) is *stateless* because stateful mode mints an
73/// in-memory `Mcp-Session-Id` that dies on app restart / idle / SSE drop, after which rmcp answers
74/// `422` and generic MCP clients wedge for the whole run. Opt into stateful only if your client
75/// needs the session protocol. (Note: Victauri does not currently implement server-initiated
76/// resource-update push, so neither transport delivers MCP resource *subscription* notifications;
77/// the `subscribe` capability is intentionally not advertised — read resources on demand.)
78#[doc(hidden)]
79pub fn build_app_stateful(
80    state: Arc<VictauriState>,
81    bridge: Arc<dyn WebviewBridge>,
82    auth_token: Option<String>,
83) -> axum::Router {
84    build_app_full_inner(state, bridge, auth_token, None, true)
85}
86
87fn build_app_full_inner(
88    state: Arc<VictauriState>,
89    bridge: Arc<dyn WebviewBridge>,
90    auth_token: Option<String>,
91    rate_limiter: Option<Arc<crate::auth::RateLimiterState>>,
92    stateful: bool,
93) -> axum::Router {
94    // Normalize an empty/whitespace-only auth token to "no auth" (audit B2) so the
95    // server is never "looks protected, isn't".
96    let auth_token = normalize_auth_token(auth_token);
97
98    // Capture the host app's identity for `/info` (first-contact verification: an agent
99    // can confirm it reached the RIGHT app, not another Victauri instance on a shared port).
100    let tauri_cfg = bridge.tauri_config();
101    let app_identifier = tauri_cfg
102        .get("identifier")
103        .and_then(|v| v.as_str())
104        .map(String::from);
105    let app_product_name = tauri_cfg
106        .get("product_name")
107        .and_then(|v| v.as_str())
108        .map(String::from);
109
110    let handler = VictauriMcpHandler::new(state.clone(), bridge);
111    let rest = super::rest::router(handler.clone());
112
113    // Run the Streamable-HTTP MCP transport STATELESS by default (rmcp's default is stateful).
114    //
115    // Why: stateful mode mints an in-memory `Mcp-Session-Id` at `initialize` that every later
116    // request must echo. That session dies on app restart (the in-memory store is gone — and a
117    // `tauri dev` app restarts constantly), on idle eviction, or on SSE-stream drop. rmcp then
118    // answers the next call with `422 "expected initialize request"`. The MCP spec signals an
119    // expired session with `404` (clients re-init on that); `422` is non-standard, so a generic
120    // MCP client (e.g. the agent harness, which speaks rmcp directly and can't use our recovering
121    // `victauri bridge`) never recognises it as "re-init needed" and stays wedged for the whole
122    // run — the root cause of falling back to the REST API for everything.
123    //
124    // Stateless mode has no session id and no session to lose, so the 422 class cannot occur. The
125    // handler is already built per-request (`move || Ok(handler.clone())` above), exactly what
126    // stateless mode needs. `with_json_response(true)` returns `application/json` directly instead
127    // of an SSE frame for these request/response tools (the test client and `victauri bridge`
128    // already parse JSON-or-SSE, so this is transparent). The only capability given up is
129    // server-initiated SSE push — i.e. MCP resource *subscriptions* (`victauri://{ipc-log,windows,
130    // state}` notify); all 35 request/response tools and one-shot `resources/read` are unaffected.
131    // `build_app_stateful` (`stateful = true`) restores the session/SSE transport for subscribers.
132    //
133    // NB: `StreamableHttpServerConfig` is `#[non_exhaustive]`, so it cannot be built with struct
134    // literal syntax outside rmcp — the builder methods are the only way to override defaults.
135    let mcp_config = if stateful {
136        StreamableHttpServerConfig::default()
137    } else {
138        StreamableHttpServerConfig::default()
139            .with_stateful_mode(false)
140            .with_json_response(true)
141    };
142    let mcp_service = StreamableHttpService::new(
143        move || Ok(handler.clone()),
144        Arc::new(LocalSessionManager::default()),
145        mcp_config,
146    );
147
148    let auth_state = Arc::new(crate::auth::AuthState {
149        token: auth_token.clone(),
150    });
151    let info_state = state.clone();
152    let info_auth = auth_token.is_some();
153
154    let privacy_enabled = !state.privacy.disabled_tools.is_empty()
155        || state.privacy.command_allowlist.is_some()
156        || !state.privacy.command_blocklist.is_empty()
157        || state.privacy.redaction_enabled;
158
159    let mut router = axum::Router::new()
160        .route_service("/mcp", mcp_service)
161        .nest("/api/tools", rest)
162        .route(
163            "/info",
164            axum::routing::get(move || {
165                let s = info_state.clone();
166                let app_id = app_identifier.clone();
167                let app_name = app_product_name.clone();
168                async move {
169                    axum::Json(serde_json::json!({
170                        "name": "victauri",
171                        "description": "Full-stack Tauri app inspection: webview + IPC + Rust backend + SQLite",
172                        "version": env!("CARGO_PKG_VERSION"),
173                        "protocol": "mcp",
174                        // Host-app identity — lets an agent verify it reached the intended app.
175                        "app_identifier": app_id,
176                        "app_product_name": app_name,
177                        "capabilities": ["webview", "ipc", "backend", "database", "filesystem"],
178                        "commands_registered": s.registry.count(),
179                        "events_captured": s.event_log.len(),
180                        "port": s.port.load(Ordering::Relaxed),
181                        "auth_required": info_auth,
182                        "privacy_mode": privacy_enabled,
183                    }))
184                }
185            }),
186        );
187
188    if auth_token.is_some() {
189        router = router.layer(axum::middleware::from_fn_with_state(
190            auth_state,
191            crate::auth::require_auth,
192        ));
193    }
194
195    let limiter = rate_limiter.unwrap_or_else(crate::auth::default_rate_limiter);
196    router = router.layer(axum::middleware::from_fn_with_state(
197        limiter,
198        crate::auth::rate_limit,
199    ));
200
201    router
202        .route(
203            "/health",
204            axum::routing::get(|| async { axum::Json(serde_json::json!({"status": "ok"})) }),
205        )
206        .layer(DefaultBodyLimit::max(2 * 1024 * 1024))
207        .layer(ConcurrencyLimitLayer::new(64))
208        .layer(axum::middleware::from_fn(crate::auth::security_headers))
209        .layer(axum::middleware::from_fn(crate::auth::origin_guard))
210        .layer(axum::middleware::from_fn(crate::auth::dns_rebinding_guard))
211}
212
213#[doc(hidden)]
214#[allow(dead_code)]
215pub mod tests_support {
216    /// Expose memory stats for integration tests.
217    #[must_use]
218    pub fn get_memory_stats() -> serde_json::Value {
219        crate::memory::current_stats()
220    }
221}
222
223const PORT_FALLBACK_RANGE: u16 = 10;
224
225/// Start the MCP server on the given port with default options (no auth token).
226///
227/// # Errors
228///
229/// Returns an error if the server fails to bind to the requested port (or any port in the
230/// fallback range), or if the server exits unexpectedly.
231pub async fn start_server<R: Runtime>(
232    app_handle: tauri::AppHandle<R>,
233    state: Arc<VictauriState>,
234    port: u16,
235    shutdown_rx: tokio::sync::watch::Receiver<bool>,
236) -> anyhow::Result<()> {
237    start_server_with_options(app_handle, state, port, None, shutdown_rx).await
238}
239
240/// Start the MCP server on the given port with an optional auth token.
241///
242/// # Errors
243///
244/// Returns an error if the server fails to bind to the requested port (or any port in the
245/// fallback range), or if the server exits unexpectedly.
246pub async fn start_server_with_options<R: Runtime>(
247    app_handle: tauri::AppHandle<R>,
248    state: Arc<VictauriState>,
249    port: u16,
250    auth_token: Option<String>,
251    mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
252) -> anyhow::Result<()> {
253    let bridge: Arc<dyn WebviewBridge> = Arc::new(app_handle);
254    // Normalize once so the discovery-file token and the request gate agree (B2):
255    // an empty token must not be written to the discovery file as if auth were on.
256    let auth_token = normalize_auth_token(auth_token);
257    let token_for_file = auth_token.clone();
258    let app = build_app_with_options(state.clone(), bridge.clone(), auth_token);
259
260    let (listener, actual_port) = try_bind(port).await?;
261
262    if actual_port != port {
263        tracing::warn!("Victauri: port {port} in use, fell back to {actual_port}");
264    }
265
266    state.port.store(actual_port, Ordering::Relaxed);
267    let cfg = bridge.tauri_config();
268    let app_identifier = cfg.get("identifier").and_then(|v| v.as_str());
269    let app_product_name = cfg.get("product_name").and_then(|v| v.as_str());
270    write_port_file(actual_port, app_identifier, app_product_name);
271    // Always write a session token to the discovery directory so clients can
272    // authenticate automatically.  When auth is explicitly configured the
273    // configured token is used; otherwise a fresh UUID is generated.  The auth
274    // middleware is only enabled when `auth_token` is `Some`, so this file is
275    // purely informational when auth is off — sending the token header is a
276    // harmless no-op.
277    let discovery_token = token_for_file
278        .as_deref()
279        .map_or_else(crate::auth::generate_token, String::from);
280    write_token_file(&discovery_token);
281
282    tracing::info!("Victauri MCP server listening on 127.0.0.1:{actual_port}");
283
284    let drain_state = state.clone();
285    let drain_bridge = bridge;
286    let drain_shutdown = state.shutdown_tx.subscribe();
287    let drain_finished = state.task_tracker.track("event_drain_loop");
288    tokio::spawn(async move {
289        event_drain_loop(drain_state, drain_bridge, drain_shutdown).await;
290        drain_finished.store(true, std::sync::atomic::Ordering::Relaxed);
291    });
292
293    let mut shutdown_rx2 = shutdown_rx.clone();
294    let server = axum::serve(listener, app).with_graceful_shutdown(async move {
295        let _ = shutdown_rx.wait_for(|&v| v).await;
296        remove_port_file();
297        tracing::info!("Victauri MCP server shutting down gracefully");
298    });
299
300    tokio::select! {
301        result = server => {
302            if let Err(e) = result {
303                tracing::error!("Victauri MCP server error: {e}");
304            }
305        }
306        _ = async {
307            let _ = shutdown_rx2.wait_for(|&v| v).await;
308            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
309        } => {
310            tracing::warn!("Victauri MCP server shutdown timeout — forcing exit");
311        }
312    }
313    Ok(())
314}
315
316async fn try_bind(preferred: u16) -> anyhow::Result<(tokio::net::TcpListener, u16)> {
317    if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{preferred}")).await {
318        return Ok((listener, preferred));
319    }
320
321    for offset in 1..=PORT_FALLBACK_RANGE {
322        // Saturating/checked add: a `preferred` near u16::MAX (e.g. 65530) would
323        // otherwise overflow `preferred + offset` (panic in debug, wrap in release).
324        let Some(port) = preferred.checked_add(offset) else {
325            break;
326        };
327        if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await {
328            return Ok((listener, port));
329        }
330    }
331
332    anyhow::bail!(
333        "could not bind to any port in range {preferred}-{}",
334        preferred.saturating_add(PORT_FALLBACK_RANGE)
335    )
336}
337
338fn discovery_dir() -> std::path::PathBuf {
339    std::env::temp_dir()
340        .join("victauri")
341        .join(std::process::id().to_string())
342}
343
344#[cfg(unix)]
345fn current_euid() -> Option<u32> {
346    use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
347    use std::sync::atomic::{AtomicU64, Ordering};
348
349    static NEXT_PROBE: AtomicU64 = AtomicU64::new(0);
350    for _ in 0..16 {
351        let sequence = NEXT_PROBE.fetch_add(1, Ordering::Relaxed);
352        let probe = std::env::temp_dir().join(format!(
353            ".victauri_plugin_uidprobe_{}_{}",
354            std::process::id(),
355            sequence
356        ));
357        let file = std::fs::OpenOptions::new()
358            .write(true)
359            .create_new(true)
360            .mode(0o600)
361            .open(&probe)
362            .ok();
363        if let Some(file) = file {
364            let uid = file.metadata().ok().map(|m| m.uid());
365            drop(file);
366            let _ = std::fs::remove_file(probe);
367            if uid.is_some() {
368                return uid;
369            }
370        }
371    }
372    None
373}
374
375#[cfg(unix)]
376fn ensure_unix_private_dir(path: &std::path::Path) -> bool {
377    use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
378
379    let Some(euid) = current_euid() else {
380        return false;
381    };
382    match std::fs::symlink_metadata(path) {
383        Ok(meta) => {
384            if !meta.file_type().is_dir() || meta.uid() != euid {
385                return false;
386            }
387            if std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).is_err() {
388                return false;
389            }
390        }
391        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
392            let mut builder = std::fs::DirBuilder::new();
393            builder.mode(0o700);
394            if builder.create(path).is_err() {
395                return false;
396            }
397        }
398        Err(_) => return false,
399    }
400    unix_private_dir_is_trusted(path)
401}
402
403#[cfg(unix)]
404fn unix_private_dir_is_trusted(path: &std::path::Path) -> bool {
405    use std::os::unix::fs::{MetadataExt, PermissionsExt};
406
407    let Some(euid) = current_euid() else {
408        return false;
409    };
410    std::fs::symlink_metadata(path).is_ok_and(|meta| {
411        meta.file_type().is_dir() && meta.uid() == euid && (meta.permissions().mode() & 0o077) == 0
412    })
413}
414
415/// Restrict a file or directory to current-user-only access on Windows via `icacls`.
416#[cfg(windows)]
417#[allow(unsafe_code)]
418fn current_windows_username() -> Option<String> {
419    use windows::Win32::System::WindowsProgramming::GetUserNameW;
420    use windows::core::PWSTR;
421
422    let mut buffer = [0_u16; 257];
423    let mut len = buffer.len() as u32;
424    // SAFETY: `buffer` is writable for `len` UTF-16 code units and remains alive
425    // for the duration of the call. `GetUserNameW` writes at most that capacity.
426    unsafe {
427        GetUserNameW(Some(PWSTR(buffer.as_mut_ptr())), &raw mut len).ok()?;
428    }
429    let end = buffer
430        .iter()
431        .position(|unit| *unit == 0)
432        .unwrap_or(len as usize);
433    String::from_utf16(&buffer[..end])
434        .ok()
435        .filter(|name| !name.is_empty())
436}
437
438/// NUL-terminated UTF-16 encoding of a path for the Win32 `*W` APIs.
439#[cfg(windows)]
440fn to_wide(path: &std::path::Path) -> Vec<u16> {
441    use std::os::windows::ffi::OsStrExt;
442    path.as_os_str().encode_wide().chain(Some(0)).collect()
443}
444
445/// A standalone, owned copy of the current process user's SID.
446///
447/// `GetTokenInformation` returns a `TOKEN_USER` whose `Sid` pointer aliases into the
448/// token-info buffer; we copy the SID bytes out so the value is self-contained and the
449/// pointer stays valid for the lifetime of this struct.
450#[cfg(windows)]
451struct OwnedSid(Vec<u8>);
452
453#[cfg(windows)]
454impl OwnedSid {
455    fn as_psid(&self) -> windows::Win32::Security::PSID {
456        windows::Win32::Security::PSID(self.0.as_ptr() as *mut core::ffi::c_void)
457    }
458}
459
460/// Copy the SID from a token-information class into an owned buffer.
461///
462/// Used for `TokenUser` (the account SID) and `TokenOwner` (the SID that *owns objects
463/// this process creates*). Both `TOKEN_USER` (`.User.Sid`) and `TOKEN_OWNER` (`.Owner`)
464/// lead with the `PSID` at offset 0, so the SID pointer is read from the start of the
465/// returned buffer.
466#[cfg(windows)]
467#[allow(unsafe_code)]
468fn token_sid(class: windows::Win32::Security::TOKEN_INFORMATION_CLASS) -> Option<OwnedSid> {
469    use windows::Win32::Foundation::{CloseHandle, HANDLE};
470    use windows::Win32::Security::{GetLengthSid, GetTokenInformation, PSID, TOKEN_QUERY};
471    use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
472
473    struct TokenGuard(HANDLE);
474    impl Drop for TokenGuard {
475        fn drop(&mut self) {
476            // SAFETY: `self.0` came from `OpenProcessToken` and is closed exactly once.
477            unsafe {
478                let _ = CloseHandle(self.0);
479            }
480        }
481    }
482
483    let mut token = HANDLE::default();
484    // SAFETY: `GetCurrentProcess` returns a pseudo-handle valid for the call; `token` is a
485    // writable out-param. On success it owns a real handle, closed by `TokenGuard` below.
486    unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token).ok()? };
487    let _guard = TokenGuard(token);
488
489    let mut len = 0_u32;
490    // SAFETY: size probe — a null buffer with len 0 makes the call write the required size
491    // into `len` and fail with ERROR_INSUFFICIENT_BUFFER (ignored; we only want `len`).
492    unsafe {
493        let _ = GetTokenInformation(token, class, None, 0, &raw mut len);
494    }
495    if len == 0 {
496        return None;
497    }
498    let mut buf = vec![0_u8; len as usize];
499    // SAFETY: `buf` is writable for `len` bytes; on success it holds the requested struct.
500    unsafe {
501        GetTokenInformation(
502            token,
503            class,
504            Some(buf.as_mut_ptr().cast::<core::ffi::c_void>()),
505            len,
506            &raw mut len,
507        )
508        .ok()?;
509    }
510    // SAFETY: both `TOKEN_USER` and `TOKEN_OWNER` lead with the `PSID` at offset 0, so the
511    // SID pointer is the first pointer-sized field of `buf`.
512    let sid_ptr = unsafe { *buf.as_ptr().cast::<PSID>() };
513    // SAFETY: `sid_ptr` points to a valid SID within `buf`.
514    let sid_len = unsafe { GetLengthSid(sid_ptr) };
515    if sid_len == 0 {
516        return None;
517    }
518    let mut sid = vec![0_u8; sid_len as usize];
519    // SAFETY: `sid_ptr` is valid for `sid_len` bytes (per `GetLengthSid`); `sid` has capacity.
520    unsafe {
521        core::ptr::copy_nonoverlapping(sid_ptr.0.cast::<u8>(), sid.as_mut_ptr(), sid_len as usize);
522    }
523    Some(OwnedSid(sid))
524}
525
526/// SIDs that legitimately own a directory *this* process creates: the token USER and the
527/// token's default OWNER. They are identical for a normal user, but an **elevated** admin
528/// token's default owner is the `BUILTIN\Administrators` group — so objects an elevated
529/// process creates are owned by that group, not the user. Accepting either is what makes
530/// the ownership check correct under elevation (where it would otherwise reject every
531/// directory we create and break discovery entirely).
532#[cfg(windows)]
533fn acceptable_owner_sids() -> Vec<OwnedSid> {
534    use windows::Win32::Security::{TokenOwner, TokenUser};
535    [TokenUser, TokenOwner]
536        .into_iter()
537        .filter_map(token_sid)
538        .collect()
539}
540
541/// True iff `path` exists and its owner SID is one this process would create objects as
542/// (its token user, or — under elevation — its token's default owner group).
543///
544/// This is the Windows counterpart to the Unix uid check: it refuses a discovery
545/// directory an attacker pre-created on a shared TEMP (the attacker would be its owner),
546/// closing the PID-preplant vector before any token is trusted.
547#[cfg(windows)]
548#[allow(unsafe_code)]
549fn dir_owned_by_current_user(path: &std::path::Path) -> bool {
550    use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
551    use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
552    use windows::Win32::Security::{
553        EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
554    };
555    use windows::core::PCWSTR;
556
557    let acceptable = acceptable_owner_sids();
558    if acceptable.is_empty() {
559        return false;
560    }
561    let wide = to_wide(path);
562    let mut owner = PSID::default();
563    let mut psd = PSECURITY_DESCRIPTOR::default();
564    // SAFETY: `wide` is a NUL-terminated path; we request OWNER info only; `owner` aliases
565    // into `psd`, which the OS allocates and we free with `LocalFree` below.
566    let rc = unsafe {
567        GetNamedSecurityInfoW(
568            PCWSTR(wide.as_ptr()),
569            SE_FILE_OBJECT,
570            OWNER_SECURITY_INFORMATION,
571            Some(&raw mut owner),
572            None,
573            None,
574            None,
575            &raw mut psd,
576        )
577    };
578    if rc != ERROR_SUCCESS {
579        return false;
580    }
581    // SAFETY: `owner` (within `psd`) and each `sid` are valid SIDs for the comparison.
582    let owned = acceptable
583        .iter()
584        .any(|sid| unsafe { EqualSid(owner, sid.as_psid()).is_ok() });
585    // SAFETY: `psd` was allocated by `GetNamedSecurityInfoW`; freed exactly once.
586    unsafe {
587        let _ = LocalFree(Some(HLOCAL(psd.0)));
588    }
589    owned
590}
591
592/// Replace `path`'s DACL with a PROTECTED, owner-only DACL (current user: full control,
593/// inherited by children).
594///
595/// Unlike `icacls /inheritance:r /grant:r` — which strips only inherited ACEs and replaces
596/// only the owner's grant, leaving any pre-planted explicit ACE for another principal
597/// (e.g. `BUILTIN\Guests`) intact — this rebuilds the DACL from scratch and marks it
598/// PROTECTED, so NO inherited or pre-existing explicit ACE survives. Returns true on
599/// success.
600#[cfg(windows)]
601#[allow(unsafe_code)]
602fn apply_owner_only_dacl(path: &std::path::Path) -> bool {
603    use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
604    use windows::Win32::Security::Authorization::{
605        EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW,
606        SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
607    };
608    use windows::Win32::Security::{
609        ACE_FLAGS, ACL, DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION,
610    };
611    use windows::core::PWSTR;
612
613    use windows::Win32::Security::TokenUser;
614
615    // Full control (GENERIC_ALL) granted to the owner; inherited by sub-containers/objects.
616    const GENERIC_ALL_RIGHTS: u32 = 0x1000_0000;
617    const SUB_CONTAINERS_AND_OBJECTS_INHERIT: u32 = 0x3;
618
619    // Grant the token USER (the running account) full control — even when the directory's
620    // owner is the Administrators group (elevated), the running user retains access.
621    let Some(me) = token_sid(TokenUser) else {
622        return false;
623    };
624
625    let explicit = EXPLICIT_ACCESS_W {
626        grfAccessPermissions: GENERIC_ALL_RIGHTS,
627        grfAccessMode: SET_ACCESS,
628        grfInheritance: ACE_FLAGS(SUB_CONTAINERS_AND_OBJECTS_INHERIT),
629        Trustee: TRUSTEE_W {
630            pMultipleTrustee: core::ptr::null_mut(),
631            MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE,
632            TrusteeForm: TRUSTEE_IS_SID,
633            TrusteeType: TRUSTEE_IS_USER,
634            ptstrName: PWSTR(me.as_psid().0.cast::<u16>()),
635        },
636    };
637
638    let mut new_acl: *mut ACL = core::ptr::null_mut();
639    // SAFETY: one explicit entry, no prior ACL; on success `new_acl` is a LocalAlloc'd ACL
640    // that we free with `LocalFree` below.
641    let rc = unsafe { SetEntriesInAclW(Some(&[explicit]), None, &raw mut new_acl) };
642    if rc != ERROR_SUCCESS || new_acl.is_null() {
643        return false;
644    }
645
646    let mut wide = to_wide(path);
647    // SAFETY: `wide` is a NUL-terminated mutable path; `new_acl` is a valid ACL. PROTECTED
648    // strips inheritance and any other explicit ACE, leaving exactly the owner-only DACL.
649    let set_rc = unsafe {
650        SetNamedSecurityInfoW(
651            PWSTR(wide.as_mut_ptr()),
652            SE_FILE_OBJECT,
653            DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
654            None,
655            None,
656            Some(new_acl),
657            None,
658        )
659    };
660    // SAFETY: `new_acl` came from `SetEntriesInAclW`; freed exactly once.
661    unsafe {
662        let _ = LocalFree(Some(HLOCAL(new_acl.cast::<core::ffi::c_void>())));
663    }
664    set_rc == ERROR_SUCCESS
665}
666
667/// Best-effort `icacls` fallback used only if the Win32 DACL replacement fails (e.g. an
668/// unusual filesystem). Strips inherited + common world/group principals and grants the
669/// owner. Weaker than `apply_owner_only_dacl` (a custom-SID pre-plant could survive), so
670/// it runs only when the robust path is unavailable.
671#[cfg(windows)]
672fn icacls_restrict_to_current_user(path: &std::path::Path) -> bool {
673    let Some(username) = current_windows_username() else {
674        return false;
675    };
676    let path_str = path.to_string_lossy();
677    std::process::Command::new("icacls")
678        .args([
679            &*path_str,
680            "/inheritance:r",
681            "/remove",
682            "*S-1-1-0",
683            "*S-1-5-32-545",
684            "*S-1-5-11",
685            "/grant:r",
686            &format!("{username}:F"),
687            "/q",
688        ])
689        .stdin(std::process::Stdio::null())
690        .stdout(std::process::Stdio::null())
691        .stderr(std::process::Stdio::null())
692        .status()
693        .is_ok_and(|status| status.success())
694}
695
696/// Lock `path` down to owner-only access. Robust path first (PROTECTED owner-only DACL via
697/// the Win32 security API), falling back to `icacls` only if that fails — fail-closed
698/// (a `false` return makes the caller refuse and remove the directory).
699#[cfg(windows)]
700fn restrict_to_current_user(path: &std::path::Path) -> bool {
701    if apply_owner_only_dacl(path) {
702        return true;
703    }
704    tracing::warn!(
705        "owner-only DACL apply failed for {}; falling back to icacls",
706        path.display()
707    );
708    icacls_restrict_to_current_user(path)
709}
710
711/// Trust the discovery path only when both the shared root and PID directory are owned
712/// by this process's effective user. Refuse planted paths instead of deleting them.
713fn ensure_private_dir(dir: &std::path::Path) -> bool {
714    #[cfg(unix)]
715    {
716        let Some(root) = dir.parent() else {
717            return false;
718        };
719        if !ensure_unix_private_dir(root) || !ensure_unix_private_dir(dir) {
720            tracing::warn!("refusing untrusted discovery path {}", dir.display());
721            return false;
722        }
723    }
724    #[cfg(not(unix))]
725    {
726        if std::fs::create_dir_all(dir).is_err() {
727            return false;
728        }
729        #[cfg(windows)]
730        {
731            // Refuse a directory we don't own — on a shared TEMP an attacker who pre-created
732            // our PID dir would be its owner. Mirrors the Unix uid check; defeats PID-preplant
733            // before any token is written/trusted. (A dir WE just created we own, so this
734            // passes for the normal path.)
735            if !dir_owned_by_current_user(dir) {
736                tracing::warn!(
737                    "refusing discovery dir not owned by current user: {}",
738                    dir.display()
739                );
740                let _ = std::fs::remove_dir_all(dir);
741                return false;
742            }
743            if !restrict_to_current_user(dir) {
744                let _ = std::fs::remove_dir_all(dir);
745                return false;
746            }
747        }
748    }
749    true
750}
751
752/// Write `contents` to `path` as a fresh, user-only file. Uses exclusive
753/// (`create_new` / `O_EXCL`) creation so a pre-planted file OR symlink at `path`
754/// is refused rather than written through, and sets `0600` at creation on Unix so
755/// there is no window where the file exists with default-umask permissions.
756fn write_private_file(path: &std::path::Path, contents: &str) {
757    // Clear any stale/pre-planted entry (symlink-aware) so our exclusive create
758    // succeeds for a fresh file; a symlink racing in afterwards is refused by
759    // `create_new` (O_EXCL treats a final-component symlink as "exists").
760    if std::fs::symlink_metadata(path).is_ok() {
761        let _ = std::fs::remove_file(path);
762    }
763    #[cfg(unix)]
764    let result = {
765        use std::io::Write;
766        use std::os::unix::fs::OpenOptionsExt;
767        std::fs::OpenOptions::new()
768            .write(true)
769            .create_new(true)
770            .mode(0o600)
771            .open(path)
772            .and_then(|mut f| f.write_all(contents.as_bytes()))
773    };
774    #[cfg(not(unix))]
775    let result = {
776        use std::io::Write;
777        std::fs::OpenOptions::new()
778            .write(true)
779            .create_new(true)
780            .open(path)
781            .and_then(|mut f| f.write_all(contents.as_bytes()))
782    };
783    // Report a write failure; on Windows additionally lock the new file down to the
784    // current user and remove it if the ACL cannot be applied (never leave a discovery
785    // file world-readable). Split per-platform so neither config trips `-D warnings`:
786    // the Windows-only post-step would otherwise make an early `return` needless on Unix.
787    #[cfg(windows)]
788    match result {
789        Ok(()) => {
790            if !restrict_to_current_user(path) {
791                let _ = std::fs::remove_file(path);
792                tracing::warn!("could not restrict discovery file {}", path.display());
793            }
794        }
795        Err(e) => {
796            tracing::debug!("could not write discovery file {}: {e}", path.display());
797        }
798    }
799    #[cfg(not(windows))]
800    if let Err(e) = result {
801        tracing::debug!("could not write discovery file {}: {e}", path.display());
802    }
803}
804
805fn write_port_file(port: u16, identifier: Option<&str>, product_name: Option<&str>) {
806    let dir = discovery_dir();
807    if !ensure_private_dir(&dir) {
808        return;
809    }
810    write_private_file(&dir.join("port"), &port.to_string());
811    // Write metadata for multi-server discovery. The app `identifier` lets a discovery
812    // client (e.g. `victauri bridge --app <id>`) select the RIGHT app when several Victauri
813    // instances are running, instead of guessing — the root cause of agents binding to the
814    // wrong process on a shared port.
815    let metadata = serde_json::json!({
816        "pid": std::process::id(),
817        "port": port,
818        "identifier": identifier,
819        "product_name": product_name,
820        "started_at": chrono::Utc::now().to_rfc3339(),
821        "version": env!("CARGO_PKG_VERSION"),
822    });
823    write_private_file(&dir.join("metadata.json"), &metadata.to_string());
824}
825
826fn write_token_file(token: &str) {
827    let dir = discovery_dir();
828    if !ensure_private_dir(&dir) {
829        return;
830    }
831    write_private_file(&dir.join("token"), token);
832}
833
834fn remove_port_file() {
835    let dir = discovery_dir();
836    #[cfg(unix)]
837    {
838        let Some(root) = dir.parent() else {
839            return;
840        };
841        if !unix_private_dir_is_trusted(root) || !unix_private_dir_is_trusted(&dir) {
842            return;
843        }
844    }
845    let _ = std::fs::remove_dir_all(dir);
846}
847
848/// Parse a single bridge event JSON value into an [`AppEvent`](victauri_core::AppEvent).
849///
850/// Returns `None` for unrecognised event types, allowing callers to skip them.
851#[must_use]
852pub fn parse_bridge_event(ev: &serde_json::Value) -> Option<victauri_core::AppEvent> {
853    use chrono::Utc;
854    use victauri_core::AppEvent;
855
856    let event_type = ev.get("type").and_then(|t| t.as_str()).unwrap_or("");
857    let now = Utc::now();
858
859    let app_event = match event_type {
860        "console" => AppEvent::Console {
861            level: ev
862                .get("level")
863                .and_then(|l| l.as_str())
864                .unwrap_or("log")
865                .to_string(),
866            message: ev
867                .get("message")
868                .and_then(|m| m.as_str())
869                .unwrap_or("")
870                .to_string(),
871            timestamp: now,
872        },
873        "dom_mutation" => AppEvent::DomMutation {
874            webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
875            timestamp: now,
876            mutation_count: ev
877                .get("count")
878                .and_then(serde_json::Value::as_u64)
879                .unwrap_or(0) as u32,
880        },
881        "ipc" => {
882            let cmd = ev
883                .get("command")
884                .and_then(|c| c.as_str())
885                .unwrap_or("unknown");
886            AppEvent::Ipc(victauri_core::IpcCall {
887                id: uuid::Uuid::new_v4().to_string(),
888                command: cmd.to_string(),
889                timestamp: now,
890                result: match ev.get("status").and_then(|s| s.as_str()) {
891                    Some("ok") => victauri_core::IpcResult::Ok(serde_json::Value::Null),
892                    Some("error") => victauri_core::IpcResult::Err("error".to_string()),
893                    _ => victauri_core::IpcResult::Pending,
894                },
895                duration_ms: ev
896                    .get("duration_ms")
897                    .and_then(serde_json::Value::as_f64)
898                    .map(|d| d as u64),
899                arg_size_bytes: 0,
900                webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
901            })
902        }
903        "network" => AppEvent::StateChange {
904            key: format!(
905                "network.{}",
906                ev.get("method").and_then(|m| m.as_str()).unwrap_or("GET")
907            ),
908            timestamp: now,
909            caused_by: ev
910                .get("url")
911                .and_then(|u| u.as_str())
912                .map(std::string::ToString::to_string),
913        },
914        "navigation" => AppEvent::WindowEvent {
915            label: DEFAULT_WEBVIEW_LABEL.to_string(),
916            event: format!(
917                "navigation.{}",
918                ev.get("nav_type")
919                    .and_then(|n| n.as_str())
920                    .unwrap_or("unknown")
921            ),
922            timestamp: now,
923        },
924        "dom_interaction" => {
925            let action_str = ev.get("action").and_then(|a| a.as_str()).unwrap_or("click");
926            let action = match action_str {
927                "click" => victauri_core::InteractionKind::Click,
928                "double_click" => victauri_core::InteractionKind::DoubleClick,
929                "fill" => victauri_core::InteractionKind::Fill,
930                "key_press" => victauri_core::InteractionKind::KeyPress,
931                "select" => victauri_core::InteractionKind::Select,
932                "navigate" => victauri_core::InteractionKind::Navigate,
933                "scroll" => victauri_core::InteractionKind::Scroll,
934                _ => victauri_core::InteractionKind::Click,
935            };
936            AppEvent::DomInteraction {
937                action,
938                selector: ev
939                    .get("selector")
940                    .and_then(|s| s.as_str())
941                    .unwrap_or("body")
942                    .to_string(),
943                value: ev
944                    .get("value")
945                    .and_then(|v| v.as_str())
946                    .map(std::string::ToString::to_string),
947                timestamp: now,
948                webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
949            }
950        }
951        _ => return None,
952    };
953
954    Some(app_event)
955}
956
957async fn event_drain_loop(
958    state: Arc<VictauriState>,
959    bridge: Arc<dyn WebviewBridge>,
960    mut shutdown: tokio::sync::watch::Receiver<bool>,
961) {
962    let mut last_drain_ts: f64 = 0.0;
963
964    loop {
965        tokio::select! {
966            _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
967            _ = shutdown.changed() => break,
968        }
969
970        let code = format!("return window.__VICTAURI__?.getEventStream({last_drain_ts})");
971        let id = uuid::Uuid::new_v4().to_string();
972        let (tx, rx) = tokio::sync::oneshot::channel();
973
974        {
975            let mut pending = state.pending_evals.lock().await;
976            if pending.len() >= MAX_PENDING_EVALS {
977                continue;
978            }
979            pending.insert(id.clone(), tx);
980        }
981
982        let id_js = super::helpers::js_string(&id);
983        let inject = format!(
984            r"
985            (async () => {{
986                try {{
987                    const __result = await (async () => {{ {code} }})();
988                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
989                        id: {id_js},
990                        result: JSON.stringify(__result)
991                    }});
992                }} catch (e) {{
993                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
994                        id: {id_js},
995                        result: JSON.stringify({{ __error: e.message }})
996                    }});
997                }}
998            }})();
999            "
1000        );
1001
1002        if bridge.eval_webview(None, &inject).is_err() {
1003            state.pending_evals.lock().await.remove(&id);
1004            continue;
1005        }
1006
1007        let Ok(Ok(result)) = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await
1008        else {
1009            state.pending_evals.lock().await.remove(&id);
1010            continue;
1011        };
1012
1013        let events: Vec<serde_json::Value> = match serde_json::from_str(&result) {
1014            Ok(v) => v,
1015            Err(_) => continue,
1016        };
1017
1018        for ev in &events {
1019            let ts = ev
1020                .get("timestamp")
1021                .and_then(serde_json::Value::as_f64)
1022                .unwrap_or(0.0);
1023            if ts > last_drain_ts {
1024                last_drain_ts = ts;
1025            }
1026
1027            if let Some(app_event) = parse_bridge_event(ev) {
1028                state.event_log.push(app_event.clone());
1029                if state.recorder.is_recording() {
1030                    state.recorder.record_event(app_event);
1031                }
1032            }
1033        }
1034    }
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::*;
1040    use victauri_core::{AppEvent, InteractionKind, IpcResult};
1041
1042    // Round-4 audit blocker #4: a pre-planted explicit ACE for an arbitrary principal
1043    // (the auditor used BUILTIN\Guests) must NOT survive the discovery-dir hardening.
1044    // Proves the robust owner-only DACL replacement closes the icacls residual.
1045    #[cfg(windows)]
1046    #[test]
1047    fn owner_only_dacl_removes_pre_planted_guests_ace() {
1048        use std::process::Command;
1049        let dir = std::env::temp_dir()
1050            .join("victauri_acl_test")
1051            .join(format!("p{}", std::process::id()));
1052        let _ = std::fs::remove_dir_all(&dir);
1053        std::fs::create_dir_all(&dir).expect("create test dir");
1054
1055        // We just created it, so the ownership guard must accept it (owner == token user,
1056        // or the Administrators group under elevation).
1057        assert!(
1058            dir_owned_by_current_user(&dir),
1059            "a freshly created dir must be recognized as owned by this process"
1060        );
1061
1062        let path_str = dir.to_string_lossy().to_string();
1063
1064        // Pre-plant an inheritable explicit ACE for BUILTIN\Guests (S-1-5-32-546).
1065        let Ok(grant) = Command::new("icacls")
1066            .args([path_str.as_str(), "/grant", "*S-1-5-32-546:(OI)(CI)F", "/q"])
1067            .output()
1068        else {
1069            let _ = std::fs::remove_dir_all(&dir);
1070            return; // icacls unavailable — skip rather than false-fail
1071        };
1072        if !grant.status.success() {
1073            let _ = std::fs::remove_dir_all(&dir);
1074            return; // could not plant the ACE (restricted env) — skip
1075        }
1076
1077        let before = Command::new("icacls")
1078            .arg(path_str.as_str())
1079            .output()
1080            .expect("icacls read");
1081        let before_s = String::from_utf8_lossy(&before.stdout);
1082        assert!(
1083            before_s.contains("Guests"),
1084            "pre-condition: the planted Guests ACE should be visible, got:\n{before_s}"
1085        );
1086
1087        // Apply the robust owner-only DACL replacement.
1088        assert!(
1089            apply_owner_only_dacl(&dir),
1090            "apply_owner_only_dacl must succeed on a directory we own"
1091        );
1092
1093        let after = Command::new("icacls")
1094            .arg(path_str.as_str())
1095            .output()
1096            .expect("icacls read");
1097        let after_s = String::from_utf8_lossy(&after.stdout);
1098        assert!(
1099            !after_s.contains("Guests"),
1100            "the pre-planted Guests ACE must NOT survive the owner-only DACL, got:\n{after_s}"
1101        );
1102
1103        let _ = std::fs::remove_dir_all(&dir);
1104    }
1105
1106    #[test]
1107    fn normalize_auth_token_collapses_empty() {
1108        // Audit B2: an empty/whitespace token must become "no auth", never an
1109        // auth-enabled-but-empty-credential state.
1110        assert_eq!(normalize_auth_token(Some(String::new())), None);
1111        assert_eq!(normalize_auth_token(Some("   ".to_string())), None);
1112        assert_eq!(normalize_auth_token(Some("\t\n".to_string())), None);
1113        // A real token is preserved; explicit None stays None.
1114        assert_eq!(
1115            normalize_auth_token(Some("secret-123".to_string())).as_deref(),
1116            Some("secret-123")
1117        );
1118        assert_eq!(normalize_auth_token(None), None);
1119    }
1120
1121    #[tokio::test]
1122    async fn try_bind_preferred_port_available() {
1123        let (listener, port) = try_bind(0).await.unwrap();
1124        let addr = listener.local_addr().unwrap();
1125        assert_eq!(port, 0);
1126        assert_ne!(addr.port(), 0); // OS assigned a real port
1127    }
1128
1129    #[tokio::test]
1130    async fn try_bind_falls_back_when_taken() {
1131        let blocker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1132        let blocked_port = blocker.local_addr().unwrap().port();
1133
1134        let (_, actual) = try_bind(blocked_port).await.unwrap();
1135        assert_ne!(actual, blocked_port);
1136        assert!(actual > blocked_port);
1137        assert!(actual <= blocked_port + PORT_FALLBACK_RANGE);
1138    }
1139
1140    #[test]
1141    fn port_file_roundtrip() {
1142        write_port_file(7777, Some("com.example.app"), Some("Example"));
1143        let dir = discovery_dir();
1144        let content = std::fs::read_to_string(dir.join("port")).unwrap();
1145        assert_eq!(content, "7777");
1146        // Metadata file written
1147        let meta: serde_json::Value =
1148            serde_json::from_str(&std::fs::read_to_string(dir.join("metadata.json")).unwrap())
1149                .unwrap();
1150        assert_eq!(meta["port"], 7777);
1151        assert_eq!(meta["pid"], std::process::id());
1152        // App identity must be recorded so a discovery client can select the RIGHT app.
1153        assert_eq!(meta["identifier"], "com.example.app");
1154        assert_eq!(meta["product_name"], "Example");
1155        remove_port_file();
1156        assert!(!dir.exists());
1157    }
1158
1159    #[cfg(unix)]
1160    #[test]
1161    fn private_dir_refuses_symlink_without_chmodding_target() {
1162        use std::os::unix::fs::PermissionsExt;
1163
1164        let base = tempfile::tempdir().unwrap();
1165        let target = base.path().join("target");
1166        let link = base.path().join("link");
1167        std::fs::create_dir(&target).unwrap();
1168        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1169        std::os::unix::fs::symlink(&target, &link).unwrap();
1170
1171        assert!(!ensure_unix_private_dir(&link));
1172        let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1173        assert_eq!(mode, 0o755, "symlink target permissions must be untouched");
1174    }
1175
1176    // ── parse_bridge_event: dom_interaction ────────────────────────────────
1177
1178    #[test]
1179    fn parse_dom_interaction_click() {
1180        let ev = serde_json::json!({
1181            "type": "dom_interaction",
1182            "action": "click",
1183            "selector": "#submit-btn",
1184        });
1185        let result = parse_bridge_event(&ev).expect("should produce an event");
1186        match result {
1187            AppEvent::DomInteraction {
1188                action,
1189                selector,
1190                value,
1191                webview_label,
1192                ..
1193            } => {
1194                assert_eq!(action, InteractionKind::Click);
1195                assert_eq!(selector, "#submit-btn");
1196                assert!(value.is_none());
1197                assert_eq!(webview_label, "main");
1198            }
1199            other => panic!("expected DomInteraction, got {other:?}"),
1200        }
1201    }
1202
1203    #[test]
1204    fn parse_dom_interaction_fill_with_value() {
1205        let ev = serde_json::json!({
1206            "type": "dom_interaction",
1207            "action": "fill",
1208            "selector": "input[name=email]",
1209            "value": "test@example.com",
1210        });
1211        let result = parse_bridge_event(&ev).expect("should produce an event");
1212        match result {
1213            AppEvent::DomInteraction {
1214                action,
1215                selector,
1216                value,
1217                ..
1218            } => {
1219                assert_eq!(action, InteractionKind::Fill);
1220                assert_eq!(selector, "input[name=email]");
1221                assert_eq!(value.as_deref(), Some("test@example.com"));
1222            }
1223            other => panic!("expected DomInteraction, got {other:?}"),
1224        }
1225    }
1226
1227    #[test]
1228    fn parse_dom_interaction_key_press() {
1229        let ev = serde_json::json!({
1230            "type": "dom_interaction",
1231            "action": "key_press",
1232            "selector": "body",
1233            "value": "Enter",
1234        });
1235        let result = parse_bridge_event(&ev).expect("should produce an event");
1236        match result {
1237            AppEvent::DomInteraction { action, value, .. } => {
1238                assert_eq!(action, InteractionKind::KeyPress);
1239                assert_eq!(value.as_deref(), Some("Enter"));
1240            }
1241            other => panic!("expected DomInteraction, got {other:?}"),
1242        }
1243    }
1244
1245    #[test]
1246    fn parse_dom_interaction_unknown_action_defaults_to_click() {
1247        let ev = serde_json::json!({
1248            "type": "dom_interaction",
1249            "action": "swipe_left",
1250            "selector": ".card",
1251        });
1252        let result = parse_bridge_event(&ev).expect("should produce an event");
1253        match result {
1254            AppEvent::DomInteraction { action, .. } => {
1255                assert_eq!(action, InteractionKind::Click);
1256            }
1257            other => panic!("expected DomInteraction, got {other:?}"),
1258        }
1259    }
1260
1261    #[test]
1262    fn parse_dom_interaction_missing_action_defaults_to_click() {
1263        let ev = serde_json::json!({
1264            "type": "dom_interaction",
1265            "selector": "button",
1266        });
1267        let result = parse_bridge_event(&ev).expect("should produce an event");
1268        match result {
1269            AppEvent::DomInteraction { action, .. } => {
1270                assert_eq!(action, InteractionKind::Click);
1271            }
1272            other => panic!("expected DomInteraction, got {other:?}"),
1273        }
1274    }
1275
1276    #[test]
1277    fn parse_dom_interaction_missing_selector_defaults_to_body() {
1278        let ev = serde_json::json!({
1279            "type": "dom_interaction",
1280            "action": "scroll",
1281        });
1282        let result = parse_bridge_event(&ev).expect("should produce an event");
1283        match result {
1284            AppEvent::DomInteraction {
1285                action, selector, ..
1286            } => {
1287                assert_eq!(action, InteractionKind::Scroll);
1288                assert_eq!(selector, "body");
1289            }
1290            other => panic!("expected DomInteraction, got {other:?}"),
1291        }
1292    }
1293
1294    #[test]
1295    fn parse_dom_interaction_all_action_kinds() {
1296        let cases = [
1297            ("click", InteractionKind::Click),
1298            ("double_click", InteractionKind::DoubleClick),
1299            ("fill", InteractionKind::Fill),
1300            ("key_press", InteractionKind::KeyPress),
1301            ("select", InteractionKind::Select),
1302            ("navigate", InteractionKind::Navigate),
1303            ("scroll", InteractionKind::Scroll),
1304        ];
1305        for (action_str, expected_kind) in cases {
1306            let ev = serde_json::json!({
1307                "type": "dom_interaction",
1308                "action": action_str,
1309                "selector": "body",
1310            });
1311            let result = parse_bridge_event(&ev)
1312                .unwrap_or_else(|| panic!("should produce event for action {action_str}"));
1313            match result {
1314                AppEvent::DomInteraction { action, .. } => {
1315                    assert_eq!(action, expected_kind, "mismatch for action {action_str}");
1316                }
1317                other => panic!("expected DomInteraction for {action_str}, got {other:?}"),
1318            }
1319        }
1320    }
1321
1322    // ── parse_bridge_event: ipc ────────────────────────────────────────────
1323
1324    #[test]
1325    fn parse_ipc_status_ok() {
1326        let ev = serde_json::json!({
1327            "type": "ipc",
1328            "command": "greet",
1329            "status": "ok",
1330            "duration_ms": 42.0,
1331        });
1332        let result = parse_bridge_event(&ev).expect("should produce an event");
1333        match result {
1334            AppEvent::Ipc(call) => {
1335                assert_eq!(call.command, "greet");
1336                assert_eq!(call.result, IpcResult::Ok(serde_json::Value::Null));
1337                assert_eq!(call.duration_ms, Some(42));
1338                assert_eq!(call.webview_label, "main");
1339            }
1340            other => panic!("expected Ipc, got {other:?}"),
1341        }
1342    }
1343
1344    #[test]
1345    fn parse_ipc_status_error() {
1346        let ev = serde_json::json!({
1347            "type": "ipc",
1348            "command": "save_file",
1349            "status": "error",
1350        });
1351        let result = parse_bridge_event(&ev).expect("should produce an event");
1352        match result {
1353            AppEvent::Ipc(call) => {
1354                assert_eq!(call.command, "save_file");
1355                assert_eq!(call.result, IpcResult::Err("error".to_string()));
1356            }
1357            other => panic!("expected Ipc, got {other:?}"),
1358        }
1359    }
1360
1361    #[test]
1362    fn parse_ipc_status_pending() {
1363        let ev = serde_json::json!({
1364            "type": "ipc",
1365            "command": "long_task",
1366        });
1367        let result = parse_bridge_event(&ev).expect("should produce an event");
1368        match result {
1369            AppEvent::Ipc(call) => {
1370                assert_eq!(call.result, IpcResult::Pending);
1371                assert!(call.duration_ms.is_none());
1372            }
1373            other => panic!("expected Ipc, got {other:?}"),
1374        }
1375    }
1376
1377    // ── parse_bridge_event: console ────────────────────────────────────────
1378
1379    #[test]
1380    fn parse_console_event() {
1381        let ev = serde_json::json!({
1382            "type": "console",
1383            "level": "warn",
1384            "message": "deprecated API usage",
1385        });
1386        let result = parse_bridge_event(&ev).expect("should produce an event");
1387        match result {
1388            AppEvent::Console { level, message, .. } => {
1389                assert_eq!(level, "warn");
1390                assert_eq!(message, "deprecated API usage");
1391            }
1392            other => panic!("expected Console, got {other:?}"),
1393        }
1394    }
1395
1396    #[test]
1397    fn parse_console_default_level() {
1398        let ev = serde_json::json!({
1399            "type": "console",
1400            "message": "hello",
1401        });
1402        let result = parse_bridge_event(&ev).expect("should produce an event");
1403        match result {
1404            AppEvent::Console { level, message, .. } => {
1405                assert_eq!(level, "log");
1406                assert_eq!(message, "hello");
1407            }
1408            other => panic!("expected Console, got {other:?}"),
1409        }
1410    }
1411
1412    // ── parse_bridge_event: navigation ─────────────────────────────────────
1413
1414    #[test]
1415    fn parse_navigation_event() {
1416        let ev = serde_json::json!({
1417            "type": "navigation",
1418            "nav_type": "push",
1419        });
1420        let result = parse_bridge_event(&ev).expect("should produce an event");
1421        match result {
1422            AppEvent::WindowEvent { label, event, .. } => {
1423                assert_eq!(label, "main");
1424                assert_eq!(event, "navigation.push");
1425            }
1426            other => panic!("expected WindowEvent, got {other:?}"),
1427        }
1428    }
1429
1430    #[test]
1431    fn parse_navigation_default_nav_type() {
1432        let ev = serde_json::json!({ "type": "navigation" });
1433        let result = parse_bridge_event(&ev).expect("should produce an event");
1434        match result {
1435            AppEvent::WindowEvent { event, .. } => {
1436                assert_eq!(event, "navigation.unknown");
1437            }
1438            other => panic!("expected WindowEvent, got {other:?}"),
1439        }
1440    }
1441
1442    // ── parse_bridge_event: dom_mutation ───────────────────────────────────
1443
1444    #[test]
1445    fn parse_dom_mutation_event() {
1446        let ev = serde_json::json!({
1447            "type": "dom_mutation",
1448            "count": 15,
1449        });
1450        let result = parse_bridge_event(&ev).expect("should produce an event");
1451        match result {
1452            AppEvent::DomMutation {
1453                webview_label,
1454                mutation_count,
1455                ..
1456            } => {
1457                assert_eq!(webview_label, "main");
1458                assert_eq!(mutation_count, 15);
1459            }
1460            other => panic!("expected DomMutation, got {other:?}"),
1461        }
1462    }
1463
1464    // ── parse_bridge_event: network ────────────────────────────────────────
1465
1466    #[test]
1467    fn parse_network_event() {
1468        let ev = serde_json::json!({
1469            "type": "network",
1470            "method": "POST",
1471            "url": "https://api.example.com/data",
1472        });
1473        let result = parse_bridge_event(&ev).expect("should produce an event");
1474        match result {
1475            AppEvent::StateChange { key, caused_by, .. } => {
1476                assert_eq!(key, "network.POST");
1477                assert_eq!(caused_by.as_deref(), Some("https://api.example.com/data"));
1478            }
1479            other => panic!("expected StateChange, got {other:?}"),
1480        }
1481    }
1482
1483    // ── parse_bridge_event: unknown type ───────────────────────────────────
1484
1485    #[test]
1486    fn parse_unknown_type_returns_none() {
1487        let ev = serde_json::json!({
1488            "type": "custom_telemetry",
1489            "payload": 42,
1490        });
1491        assert!(parse_bridge_event(&ev).is_none());
1492    }
1493
1494    #[test]
1495    fn parse_missing_type_field_returns_none() {
1496        let ev = serde_json::json!({ "data": "no type here" });
1497        assert!(parse_bridge_event(&ev).is_none());
1498    }
1499
1500    #[test]
1501    fn parse_empty_object_returns_none() {
1502        let ev = serde_json::json!({});
1503        assert!(parse_bridge_event(&ev).is_none());
1504    }
1505}