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