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