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