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    // Per-window high-water marks. A single shared timestamp made every window
963    // after the first miss any events older than the previous window's latest —
964    // so the Rust event_log, the recorder (time-travel), and `explain` were blind
965    // to every non-default window (e.g. 4DA's notification/briefing windows).
966    // Track a watermark per label and drain every live window.
967    let mut watermarks: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
968
969    loop {
970        tokio::select! {
971            _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
972            _ = shutdown.changed() => break,
973        }
974
975        let labels = bridge.list_window_labels();
976        if labels.is_empty() {
977            continue;
978        }
979        // Drop watermarks for windows that have closed so the map can't grow
980        // unbounded across many ephemeral windows.
981        watermarks.retain(|label, _| labels.contains(label));
982
983        // Drain all windows concurrently. A blind window (e.g. one missing the
984        // `victauri:default` capability) hangs until the 5s eval timeout; draining
985        // sequentially would let it stall every other window's drain. Concurrency
986        // keeps a healthy window's events flowing regardless of a blind sibling.
987        let mut set = tokio::task::JoinSet::new();
988        for label in &labels {
989            let since = watermarks.get(label).copied().unwrap_or(0.0);
990            let state = Arc::clone(&state);
991            let bridge = Arc::clone(&bridge);
992            let label = label.clone();
993            set.spawn(async move {
994                let newest = drain_window(&state, &bridge, &label, since).await;
995                (label, newest)
996            });
997        }
998        while let Some(res) = set.join_next().await {
999            if let Ok((label, Some(newest))) = res {
1000                watermarks.insert(label, newest);
1001            }
1002        }
1003    }
1004}
1005
1006/// Drain one window's event stream into the event log / recorder. Returns the
1007/// newest event timestamp seen (to advance the window's watermark), or `None` if
1008/// nothing was drained (pending-eval saturation, eval-injection failure, callback
1009/// timeout, or an unparseable result). Returning `None` leaves the watermark
1010/// unchanged, so a transient failure simply re-fetches the same window next tick.
1011async fn drain_window(
1012    state: &Arc<VictauriState>,
1013    bridge: &Arc<dyn WebviewBridge>,
1014    label: &str,
1015    since: f64,
1016) -> Option<f64> {
1017    let code = format!("return window.__VICTAURI__?.getEventStream({since})");
1018    let id = uuid::Uuid::new_v4().to_string();
1019    let (tx, rx) = tokio::sync::oneshot::channel();
1020
1021    {
1022        let mut pending = state.pending_evals.lock().await;
1023        if pending.len() >= MAX_PENDING_EVALS {
1024            return None;
1025        }
1026        pending.insert(id.clone(), tx);
1027    }
1028
1029    let id_js = super::helpers::js_string(&id);
1030    let inject = format!(
1031        r"
1032        (async () => {{
1033            try {{
1034                const __result = await (async () => {{ {code} }})();
1035                await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
1036                    id: {id_js},
1037                    result: JSON.stringify(__result)
1038                }});
1039            }} catch (e) {{
1040                await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
1041                    id: {id_js},
1042                    result: JSON.stringify({{ __error: e.message }})
1043                }});
1044            }}
1045        }})();
1046        "
1047    );
1048
1049    if bridge.eval_webview(Some(label), &inject).is_err() {
1050        state.pending_evals.lock().await.remove(&id);
1051        return None;
1052    }
1053
1054    let Ok(Ok(result)) = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await else {
1055        state.pending_evals.lock().await.remove(&id);
1056        return None;
1057    };
1058
1059    let events: Vec<serde_json::Value> = serde_json::from_str(&result).ok()?;
1060
1061    let mut newest = since;
1062    for ev in &events {
1063        let ts = ev
1064            .get("timestamp")
1065            .and_then(serde_json::Value::as_f64)
1066            .unwrap_or(0.0);
1067        if ts > newest {
1068            newest = ts;
1069        }
1070
1071        if let Some(app_event) = parse_bridge_event(ev) {
1072            state.event_log.push(app_event.clone());
1073            if state.recorder.is_recording() {
1074                state.recorder.record_event(app_event);
1075            }
1076        }
1077    }
1078    Some(newest)
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084    use victauri_core::{AppEvent, InteractionKind, IpcResult};
1085
1086    // Round-4 audit blocker #4: a pre-planted explicit ACE for an arbitrary principal
1087    // (the auditor used BUILTIN\Guests) must NOT survive the discovery-dir hardening.
1088    // Proves the robust owner-only DACL replacement closes the icacls residual.
1089    #[cfg(windows)]
1090    #[test]
1091    fn owner_only_dacl_removes_pre_planted_guests_ace() {
1092        use std::process::Command;
1093        let dir = std::env::temp_dir()
1094            .join("victauri_acl_test")
1095            .join(format!("p{}", std::process::id()));
1096        let _ = std::fs::remove_dir_all(&dir);
1097        std::fs::create_dir_all(&dir).expect("create test dir");
1098
1099        // We just created it, so the ownership guard must accept it (owner == token user,
1100        // or the Administrators group under elevation).
1101        assert!(
1102            dir_owned_by_current_user(&dir),
1103            "a freshly created dir must be recognized as owned by this process"
1104        );
1105
1106        let path_str = dir.to_string_lossy().to_string();
1107
1108        // Pre-plant an inheritable explicit ACE for BUILTIN\Guests (S-1-5-32-546).
1109        let Ok(grant) = Command::new("icacls")
1110            .args([path_str.as_str(), "/grant", "*S-1-5-32-546:(OI)(CI)F", "/q"])
1111            .output()
1112        else {
1113            let _ = std::fs::remove_dir_all(&dir);
1114            return; // icacls unavailable — skip rather than false-fail
1115        };
1116        if !grant.status.success() {
1117            let _ = std::fs::remove_dir_all(&dir);
1118            return; // could not plant the ACE (restricted env) — skip
1119        }
1120
1121        let before = Command::new("icacls")
1122            .arg(path_str.as_str())
1123            .output()
1124            .expect("icacls read");
1125        let before_s = String::from_utf8_lossy(&before.stdout);
1126        assert!(
1127            before_s.contains("Guests"),
1128            "pre-condition: the planted Guests ACE should be visible, got:\n{before_s}"
1129        );
1130
1131        // Apply the robust owner-only DACL replacement.
1132        assert!(
1133            apply_owner_only_dacl(&dir),
1134            "apply_owner_only_dacl must succeed on a directory we own"
1135        );
1136
1137        let after = Command::new("icacls")
1138            .arg(path_str.as_str())
1139            .output()
1140            .expect("icacls read");
1141        let after_s = String::from_utf8_lossy(&after.stdout);
1142        assert!(
1143            !after_s.contains("Guests"),
1144            "the pre-planted Guests ACE must NOT survive the owner-only DACL, got:\n{after_s}"
1145        );
1146
1147        let _ = std::fs::remove_dir_all(&dir);
1148    }
1149
1150    #[test]
1151    fn normalize_auth_token_collapses_empty() {
1152        // Audit B2: an empty/whitespace token must become "no auth", never an
1153        // auth-enabled-but-empty-credential state.
1154        assert_eq!(normalize_auth_token(Some(String::new())), None);
1155        assert_eq!(normalize_auth_token(Some("   ".to_string())), None);
1156        assert_eq!(normalize_auth_token(Some("\t\n".to_string())), None);
1157        // A real token is preserved; explicit None stays None.
1158        assert_eq!(
1159            normalize_auth_token(Some("secret-123".to_string())).as_deref(),
1160            Some("secret-123")
1161        );
1162        assert_eq!(normalize_auth_token(None), None);
1163    }
1164
1165    #[tokio::test]
1166    async fn try_bind_preferred_port_available() {
1167        let (listener, port) = try_bind(0).await.unwrap();
1168        let addr = listener.local_addr().unwrap();
1169        assert_eq!(port, 0);
1170        assert_ne!(addr.port(), 0); // OS assigned a real port
1171    }
1172
1173    #[tokio::test]
1174    async fn try_bind_falls_back_when_taken() {
1175        let blocker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1176        let blocked_port = blocker.local_addr().unwrap().port();
1177
1178        let (_, actual) = try_bind(blocked_port).await.unwrap();
1179        assert_ne!(actual, blocked_port);
1180        assert!(actual > blocked_port);
1181        assert!(actual <= blocked_port + PORT_FALLBACK_RANGE);
1182    }
1183
1184    #[test]
1185    fn port_file_roundtrip() {
1186        write_port_file(7777, Some("com.example.app"), Some("Example"));
1187        let dir = discovery_dir();
1188        let content = std::fs::read_to_string(dir.join("port")).unwrap();
1189        assert_eq!(content, "7777");
1190        // Metadata file written
1191        let meta: serde_json::Value =
1192            serde_json::from_str(&std::fs::read_to_string(dir.join("metadata.json")).unwrap())
1193                .unwrap();
1194        assert_eq!(meta["port"], 7777);
1195        assert_eq!(meta["pid"], std::process::id());
1196        // App identity must be recorded so a discovery client can select the RIGHT app.
1197        assert_eq!(meta["identifier"], "com.example.app");
1198        assert_eq!(meta["product_name"], "Example");
1199        remove_port_file();
1200        assert!(!dir.exists());
1201    }
1202
1203    #[cfg(unix)]
1204    #[test]
1205    fn private_dir_refuses_symlink_without_chmodding_target() {
1206        use std::os::unix::fs::PermissionsExt;
1207
1208        let base = tempfile::tempdir().unwrap();
1209        let target = base.path().join("target");
1210        let link = base.path().join("link");
1211        std::fs::create_dir(&target).unwrap();
1212        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1213        std::os::unix::fs::symlink(&target, &link).unwrap();
1214
1215        assert!(!ensure_unix_private_dir(&link));
1216        let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1217        assert_eq!(mode, 0o755, "symlink target permissions must be untouched");
1218    }
1219
1220    // ── parse_bridge_event: dom_interaction ────────────────────────────────
1221
1222    #[test]
1223    fn parse_dom_interaction_click() {
1224        let ev = serde_json::json!({
1225            "type": "dom_interaction",
1226            "action": "click",
1227            "selector": "#submit-btn",
1228        });
1229        let result = parse_bridge_event(&ev).expect("should produce an event");
1230        match result {
1231            AppEvent::DomInteraction {
1232                action,
1233                selector,
1234                value,
1235                webview_label,
1236                ..
1237            } => {
1238                assert_eq!(action, InteractionKind::Click);
1239                assert_eq!(selector, "#submit-btn");
1240                assert!(value.is_none());
1241                assert_eq!(webview_label, "main");
1242            }
1243            other => panic!("expected DomInteraction, got {other:?}"),
1244        }
1245    }
1246
1247    #[test]
1248    fn parse_dom_interaction_fill_with_value() {
1249        let ev = serde_json::json!({
1250            "type": "dom_interaction",
1251            "action": "fill",
1252            "selector": "input[name=email]",
1253            "value": "test@example.com",
1254        });
1255        let result = parse_bridge_event(&ev).expect("should produce an event");
1256        match result {
1257            AppEvent::DomInteraction {
1258                action,
1259                selector,
1260                value,
1261                ..
1262            } => {
1263                assert_eq!(action, InteractionKind::Fill);
1264                assert_eq!(selector, "input[name=email]");
1265                assert_eq!(value.as_deref(), Some("test@example.com"));
1266            }
1267            other => panic!("expected DomInteraction, got {other:?}"),
1268        }
1269    }
1270
1271    #[test]
1272    fn parse_dom_interaction_key_press() {
1273        let ev = serde_json::json!({
1274            "type": "dom_interaction",
1275            "action": "key_press",
1276            "selector": "body",
1277            "value": "Enter",
1278        });
1279        let result = parse_bridge_event(&ev).expect("should produce an event");
1280        match result {
1281            AppEvent::DomInteraction { action, value, .. } => {
1282                assert_eq!(action, InteractionKind::KeyPress);
1283                assert_eq!(value.as_deref(), Some("Enter"));
1284            }
1285            other => panic!("expected DomInteraction, got {other:?}"),
1286        }
1287    }
1288
1289    #[test]
1290    fn parse_dom_interaction_unknown_action_defaults_to_click() {
1291        let ev = serde_json::json!({
1292            "type": "dom_interaction",
1293            "action": "swipe_left",
1294            "selector": ".card",
1295        });
1296        let result = parse_bridge_event(&ev).expect("should produce an event");
1297        match result {
1298            AppEvent::DomInteraction { action, .. } => {
1299                assert_eq!(action, InteractionKind::Click);
1300            }
1301            other => panic!("expected DomInteraction, got {other:?}"),
1302        }
1303    }
1304
1305    #[test]
1306    fn parse_dom_interaction_missing_action_defaults_to_click() {
1307        let ev = serde_json::json!({
1308            "type": "dom_interaction",
1309            "selector": "button",
1310        });
1311        let result = parse_bridge_event(&ev).expect("should produce an event");
1312        match result {
1313            AppEvent::DomInteraction { action, .. } => {
1314                assert_eq!(action, InteractionKind::Click);
1315            }
1316            other => panic!("expected DomInteraction, got {other:?}"),
1317        }
1318    }
1319
1320    #[test]
1321    fn parse_dom_interaction_missing_selector_defaults_to_body() {
1322        let ev = serde_json::json!({
1323            "type": "dom_interaction",
1324            "action": "scroll",
1325        });
1326        let result = parse_bridge_event(&ev).expect("should produce an event");
1327        match result {
1328            AppEvent::DomInteraction {
1329                action, selector, ..
1330            } => {
1331                assert_eq!(action, InteractionKind::Scroll);
1332                assert_eq!(selector, "body");
1333            }
1334            other => panic!("expected DomInteraction, got {other:?}"),
1335        }
1336    }
1337
1338    #[test]
1339    fn parse_dom_interaction_all_action_kinds() {
1340        let cases = [
1341            ("click", InteractionKind::Click),
1342            ("double_click", InteractionKind::DoubleClick),
1343            ("fill", InteractionKind::Fill),
1344            ("key_press", InteractionKind::KeyPress),
1345            ("select", InteractionKind::Select),
1346            ("navigate", InteractionKind::Navigate),
1347            ("scroll", InteractionKind::Scroll),
1348        ];
1349        for (action_str, expected_kind) in cases {
1350            let ev = serde_json::json!({
1351                "type": "dom_interaction",
1352                "action": action_str,
1353                "selector": "body",
1354            });
1355            let result = parse_bridge_event(&ev)
1356                .unwrap_or_else(|| panic!("should produce event for action {action_str}"));
1357            match result {
1358                AppEvent::DomInteraction { action, .. } => {
1359                    assert_eq!(action, expected_kind, "mismatch for action {action_str}");
1360                }
1361                other => panic!("expected DomInteraction for {action_str}, got {other:?}"),
1362            }
1363        }
1364    }
1365
1366    // ── parse_bridge_event: ipc ────────────────────────────────────────────
1367
1368    #[test]
1369    fn parse_ipc_status_ok() {
1370        let ev = serde_json::json!({
1371            "type": "ipc",
1372            "command": "greet",
1373            "status": "ok",
1374            "duration_ms": 42.0,
1375        });
1376        let result = parse_bridge_event(&ev).expect("should produce an event");
1377        match result {
1378            AppEvent::Ipc(call) => {
1379                assert_eq!(call.command, "greet");
1380                assert_eq!(call.result, IpcResult::Ok(serde_json::Value::Null));
1381                assert_eq!(call.duration_ms, Some(42));
1382                assert_eq!(call.webview_label, "main");
1383            }
1384            other => panic!("expected Ipc, got {other:?}"),
1385        }
1386    }
1387
1388    #[test]
1389    fn parse_ipc_status_error() {
1390        let ev = serde_json::json!({
1391            "type": "ipc",
1392            "command": "save_file",
1393            "status": "error",
1394        });
1395        let result = parse_bridge_event(&ev).expect("should produce an event");
1396        match result {
1397            AppEvent::Ipc(call) => {
1398                assert_eq!(call.command, "save_file");
1399                assert_eq!(call.result, IpcResult::Err("error".to_string()));
1400            }
1401            other => panic!("expected Ipc, got {other:?}"),
1402        }
1403    }
1404
1405    #[test]
1406    fn parse_ipc_status_pending() {
1407        let ev = serde_json::json!({
1408            "type": "ipc",
1409            "command": "long_task",
1410        });
1411        let result = parse_bridge_event(&ev).expect("should produce an event");
1412        match result {
1413            AppEvent::Ipc(call) => {
1414                assert_eq!(call.result, IpcResult::Pending);
1415                assert!(call.duration_ms.is_none());
1416            }
1417            other => panic!("expected Ipc, got {other:?}"),
1418        }
1419    }
1420
1421    // ── parse_bridge_event: console ────────────────────────────────────────
1422
1423    #[test]
1424    fn parse_console_event() {
1425        let ev = serde_json::json!({
1426            "type": "console",
1427            "level": "warn",
1428            "message": "deprecated API usage",
1429        });
1430        let result = parse_bridge_event(&ev).expect("should produce an event");
1431        match result {
1432            AppEvent::Console { level, message, .. } => {
1433                assert_eq!(level, "warn");
1434                assert_eq!(message, "deprecated API usage");
1435            }
1436            other => panic!("expected Console, got {other:?}"),
1437        }
1438    }
1439
1440    #[test]
1441    fn parse_console_default_level() {
1442        let ev = serde_json::json!({
1443            "type": "console",
1444            "message": "hello",
1445        });
1446        let result = parse_bridge_event(&ev).expect("should produce an event");
1447        match result {
1448            AppEvent::Console { level, message, .. } => {
1449                assert_eq!(level, "log");
1450                assert_eq!(message, "hello");
1451            }
1452            other => panic!("expected Console, got {other:?}"),
1453        }
1454    }
1455
1456    // ── parse_bridge_event: navigation ─────────────────────────────────────
1457
1458    #[test]
1459    fn parse_navigation_event() {
1460        let ev = serde_json::json!({
1461            "type": "navigation",
1462            "nav_type": "push",
1463        });
1464        let result = parse_bridge_event(&ev).expect("should produce an event");
1465        match result {
1466            AppEvent::WindowEvent { label, event, .. } => {
1467                assert_eq!(label, "main");
1468                assert_eq!(event, "navigation.push");
1469            }
1470            other => panic!("expected WindowEvent, got {other:?}"),
1471        }
1472    }
1473
1474    #[test]
1475    fn parse_navigation_default_nav_type() {
1476        let ev = serde_json::json!({ "type": "navigation" });
1477        let result = parse_bridge_event(&ev).expect("should produce an event");
1478        match result {
1479            AppEvent::WindowEvent { event, .. } => {
1480                assert_eq!(event, "navigation.unknown");
1481            }
1482            other => panic!("expected WindowEvent, got {other:?}"),
1483        }
1484    }
1485
1486    // ── parse_bridge_event: dom_mutation ───────────────────────────────────
1487
1488    #[test]
1489    fn parse_dom_mutation_event() {
1490        let ev = serde_json::json!({
1491            "type": "dom_mutation",
1492            "count": 15,
1493        });
1494        let result = parse_bridge_event(&ev).expect("should produce an event");
1495        match result {
1496            AppEvent::DomMutation {
1497                webview_label,
1498                mutation_count,
1499                ..
1500            } => {
1501                assert_eq!(webview_label, "main");
1502                assert_eq!(mutation_count, 15);
1503            }
1504            other => panic!("expected DomMutation, got {other:?}"),
1505        }
1506    }
1507
1508    // ── parse_bridge_event: network ────────────────────────────────────────
1509
1510    #[test]
1511    fn parse_network_event() {
1512        let ev = serde_json::json!({
1513            "type": "network",
1514            "method": "POST",
1515            "url": "https://api.example.com/data",
1516        });
1517        let result = parse_bridge_event(&ev).expect("should produce an event");
1518        match result {
1519            AppEvent::StateChange { key, caused_by, .. } => {
1520                assert_eq!(key, "network.POST");
1521                assert_eq!(caused_by.as_deref(), Some("https://api.example.com/data"));
1522            }
1523            other => panic!("expected StateChange, got {other:?}"),
1524        }
1525    }
1526
1527    // ── parse_bridge_event: unknown type ───────────────────────────────────
1528
1529    #[test]
1530    fn parse_unknown_type_returns_none() {
1531        let ev = serde_json::json!({
1532            "type": "custom_telemetry",
1533            "payload": 42,
1534        });
1535        assert!(parse_bridge_event(&ev).is_none());
1536    }
1537
1538    #[test]
1539    fn parse_missing_type_field_returns_none() {
1540        let ev = serde_json::json!({ "data": "no type here" });
1541        assert!(parse_bridge_event(&ev).is_none());
1542    }
1543
1544    #[test]
1545    fn parse_empty_object_returns_none() {
1546        let ev = serde_json::json!({});
1547        assert!(parse_bridge_event(&ev).is_none());
1548    }
1549}