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::GlobalConcurrencyLimitLayer;
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    // rmcp reads the `/mcp` body itself (default cap 4 MiB), so axum's `DefaultBodyLimit`
170    // below never applied to it; match the 2 MiB every other route has.
171    .with_max_request_body_bytes(super::hardening::MAX_REQUEST_BODY_BYTES);
172    let mcp_service = StreamableHttpService::new(
173        move || Ok(handler.clone()),
174        Arc::new(LocalSessionManager::default()),
175        mcp_config,
176    );
177
178    let auth_state = Arc::new(crate::auth::AuthState {
179        token: auth_token.clone(),
180    });
181    let info_state = state.clone();
182    let info_auth = auth_token.is_some();
183
184    let privacy_enabled = !state.privacy.disabled_tools.is_empty()
185        || state.privacy.command_allowlist.is_some()
186        || !state.privacy.command_blocklist.is_empty()
187        || state.privacy.redaction_enabled;
188
189    // Build `/mcp` as its own router so the stateless session-id backfill layer applies ONLY to
190    // that route. Axum applies a `.layer(...)` to the routes already registered on the router at
191    // the call site; routes chained on afterwards (`/api/tools`, `/info`, `/health`) are excluded.
192    let mut mcp_router = axum::Router::new().route_service("/mcp", mcp_service);
193    if !stateful {
194        mcp_router = mcp_router.layer(axum::middleware::from_fn(backfill_stateless_session_id));
195    }
196
197    let mut router = mcp_router
198        .nest("/api/tools", rest)
199        .route(
200            "/info",
201            axum::routing::get(move || {
202                let s = info_state.clone();
203                let app_id = app_identifier.clone();
204                let app_name = app_product_name.clone();
205                async move {
206                    axum::Json(serde_json::json!({
207                        "name": "victauri",
208                        "description": "Full-stack Tauri app inspection: webview + IPC + Rust backend + SQLite",
209                        "version": env!("CARGO_PKG_VERSION"),
210                        "protocol": "mcp",
211                        // Host-app identity — lets an agent verify it reached the intended app.
212                        "app_identifier": app_id,
213                        "app_product_name": app_name,
214                        "capabilities": ["webview", "ipc", "backend", "database", "filesystem"],
215                        "commands_registered": s.registry.count(),
216                        "events_captured": s.event_log.len(),
217                        "port": s.port.load(Ordering::Relaxed),
218                        "auth_required": info_auth,
219                        "privacy_mode": privacy_enabled,
220                    }))
221                }
222            }),
223        );
224
225    // Bodies are read under a deadline INSIDE the auth check (an unauthenticated client is
226    // refused before any body is buffered) and inside the concurrency cap below, so a client
227    // trickling its body can hold a request slot for at most `BODY_READ_TIMEOUT`.
228    router = router.layer(axum::middleware::from_fn(
229        super::hardening::read_body_with_deadline,
230    ));
231    if auth_token.is_some() {
232        router = router.layer(axum::middleware::from_fn_with_state(
233            auth_state,
234            crate::auth::require_auth,
235        ));
236    }
237
238    // The concurrency cap and body limit wrap only the API routes registered so far — NOT
239    // `/health`. A liveness probe must never queue behind 64 slow tool calls (long `wait_for`s,
240    // injected fault delays): the watchdog would then report a live app as dead.
241    // `GlobalConcurrencyLimitLayer`: ONE shared semaphore. A plain `ConcurrencyLimitLayer` is
242    // instantiated per route by axum, which gave `/mcp` and `/api/tools/*` 64 slots EACH.
243    router = router
244        .layer(DefaultBodyLimit::max(
245            super::hardening::MAX_REQUEST_BODY_BYTES,
246        ))
247        .layer(GlobalConcurrencyLimitLayer::new(64));
248
249    // `/health` is registered AFTER the auth layer (so liveness probes stay unauthenticated)
250    // but BEFORE the rate limiter below, so it is still throttled. Axum applies a `.layer` only
251    // to routes registered before it: /mcp,/api/tools,/info are auth-gated above; /health is not;
252    // the rate limiter + outer guards then cover everything registered so far.
253    router = router.route(
254        "/health",
255        axum::routing::get(|| async { axum::Json(serde_json::json!({"status": "ok"})) }),
256    );
257
258    // Requests presenting the valid token draw from their own bucket: traffic that cannot
259    // authenticate must not be able to exhaust the agent's budget. (The browser guards below
260    // run first, so a refused browser request spends no token at all.)
261    let limiter = rate_limiter.unwrap_or_else(crate::auth::default_rate_limiter);
262    let authenticated = Arc::new(crate::auth::RateLimiterState::new(limiter.max_tokens()));
263    router = router.layer(axum::middleware::from_fn_with_state(
264        Arc::new(super::hardening::SplitRateLimit {
265            token: auth_token,
266            public: limiter,
267            authenticated,
268        }),
269        super::hardening::split_rate_limit,
270    ));
271
272    router
273        .layer(axum::middleware::from_fn(require_json_from_browsers))
274        .layer(axum::middleware::from_fn(crate::auth::security_headers))
275        .layer(axum::middleware::from_fn(crate::auth::origin_guard))
276        .layer(axum::middleware::from_fn(crate::auth::dns_rebinding_guard))
277        // Outermost: every refusal above (and the rate limiter's 429) also closes its connection.
278        .layer(axum::middleware::from_fn(
279            super::hardening::close_on_rejection,
280        ))
281}
282
283/// Refuse a browser-originated request body that is not JSON (415).
284///
285/// A browser can send a cross-origin POST with a "simple" content type (`text/plain`,
286/// form-encoded, multipart) and NO CORS preflight, so a page on any localhost origin could
287/// fire tool calls blind at an `auth_disabled()` server (with auth on, the `Authorization`
288/// header already forces a preflight that fails). Every legitimate caller sends JSON, and
289/// non-browser clients (curl, scripts, the CLI) send no `Origin` header, so they are unaffected.
290async fn require_json_from_browsers(
291    req: axum::extract::Request,
292    next: axum::middleware::Next,
293) -> axum::response::Response {
294    use axum::http::{Method, StatusCode, header};
295    use axum::response::IntoResponse;
296    let has_body_method = !matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS);
297    if has_body_method && req.headers().contains_key(header::ORIGIN) {
298        let is_json = req
299            .headers()
300            .get(header::CONTENT_TYPE)
301            .and_then(|v| v.to_str().ok())
302            .and_then(|ct| ct.split(';').next())
303            .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json"));
304        if !is_json {
305            return (
306                StatusCode::UNSUPPORTED_MEDIA_TYPE,
307                "browser-originated requests must send Content-Type: application/json",
308            )
309                .into_response();
310        }
311    }
312    next.run(req).await
313}
314
315#[doc(hidden)]
316#[allow(dead_code)]
317pub mod tests_support {
318    /// Expose memory stats for integration tests.
319    #[must_use]
320    pub fn get_memory_stats() -> serde_json::Value {
321        crate::memory::current_stats()
322    }
323}
324
325const PORT_FALLBACK_RANGE: u16 = 10;
326
327/// Start the MCP server on the given port with default options (no auth token).
328///
329/// # Errors
330///
331/// Returns an error if the server fails to bind to the requested port (or any port in the
332/// fallback range), or if the server exits unexpectedly.
333pub async fn start_server<R: Runtime>(
334    app_handle: tauri::AppHandle<R>,
335    state: Arc<VictauriState>,
336    port: u16,
337    shutdown_rx: tokio::sync::watch::Receiver<bool>,
338) -> anyhow::Result<()> {
339    start_server_with_options(app_handle, state, port, None, shutdown_rx).await
340}
341
342/// Start the MCP server on the given port with an optional auth token.
343///
344/// # Errors
345///
346/// Returns an error if the server fails to bind to the requested port (or any port in the
347/// fallback range), or if the server exits unexpectedly.
348pub async fn start_server_with_options<R: Runtime>(
349    app_handle: tauri::AppHandle<R>,
350    state: Arc<VictauriState>,
351    port: u16,
352    auth_token: Option<String>,
353    shutdown_rx: tokio::sync::watch::Receiver<bool>,
354) -> anyhow::Result<()> {
355    start_server_reporting_port(app_handle, state, port, auth_token, shutdown_rx, None).await
356}
357
358/// [`start_server_with_options`], additionally reporting the port ACTUALLY bound (or the
359/// bind failure) on `bound` — the preferred port may be taken, possibly by another process.
360///
361/// # Errors
362///
363/// As [`start_server_with_options`].
364#[doc(hidden)]
365pub async fn start_server_reporting_port<R: Runtime>(
366    app_handle: tauri::AppHandle<R>,
367    state: Arc<VictauriState>,
368    port: u16,
369    auth_token: Option<String>,
370    mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
371    bound: Option<tokio::sync::oneshot::Sender<Result<u16, String>>>,
372) -> anyhow::Result<()> {
373    let bridge: Arc<dyn WebviewBridge> = Arc::new(app_handle);
374    // The bridge IS a Tauri `AppHandle`, and the server's tasks end on tokio workers; dropping
375    // the last reference there would drop a Tauri handle off the main thread — the Linux `Rc`
376    // race documented on `bridge::on_main`. It lives for the process anyway, so keep one
377    // reference forever instead of letting shutdown free it on the wrong thread.
378    std::mem::forget(Arc::clone(&bridge));
379    // Normalize once so the discovery-file token and the request gate agree (B2):
380    // an empty token must not be written to the discovery file as if auth were on.
381    let auth_token = normalize_auth_token(auth_token);
382    let token_for_file = auth_token.clone();
383    let app = build_app_with_options(state.clone(), bridge.clone(), auth_token);
384
385    let (listener, actual_port) = match try_bind(port).await {
386        Ok(bound_listener) => bound_listener,
387        Err(e) => {
388            if let Some(bound) = bound {
389                let _ = bound.send(Err(e.to_string()));
390            }
391            return Err(e);
392        }
393    };
394
395    if actual_port != port {
396        tracing::warn!("Victauri: port {port} in use, fell back to {actual_port}");
397    }
398
399    state.port.store(actual_port, Ordering::Relaxed);
400    let cfg = bridge.tauri_config();
401    let app_identifier = cfg.get("identifier").and_then(|v| v.as_str());
402    let app_product_name = cfg.get("product_name").and_then(|v| v.as_str());
403    // Always write a session token to the discovery directory so clients can
404    // authenticate automatically.  When auth is explicitly configured the
405    // configured token is used; otherwise a fresh UUID is generated.  The auth
406    // middleware is only enabled when `auth_token` is `Some`, so this file is
407    // purely informational when auth is off — sending the token header is a
408    // harmless no-op.
409    // The token is written BEFORE the port: clients discover a server by its port file and
410    // then read the token, so the reverse order left a window in which they got 401.
411    let discovery_token = token_for_file
412        .as_deref()
413        .map_or_else(crate::auth::generate_token, String::from);
414    write_token_file(&discovery_token);
415    write_port_file(actual_port, app_identifier, app_product_name);
416    if let Some(bound) = bound {
417        let _ = bound.send(Ok(actual_port));
418    }
419
420    tracing::info!("Victauri MCP server listening on 127.0.0.1:{actual_port}");
421
422    let drain_state = state.clone();
423    let drain_bridge = bridge;
424    let drain_shutdown = state.shutdown_tx.subscribe();
425    let drain_finished = state.task_tracker.track_guarded("event_drain_loop");
426    tokio::spawn(async move {
427        let _finished = drain_finished;
428        event_drain_loop(drain_state, drain_bridge, drain_shutdown).await;
429    });
430
431    let mut shutdown_rx2 = shutdown_rx.clone();
432    let server = super::hardening::serve_hardened(
433        listener,
434        app,
435        super::hardening::ServeLimits::DEFAULT,
436        async move {
437            let _ = shutdown_rx.wait_for(|&v| v).await;
438            remove_port_file();
439            tracing::info!("Victauri MCP server shutting down gracefully");
440        },
441    );
442
443    tokio::select! {
444        () = server => {}
445        _ = async {
446            let _ = shutdown_rx2.wait_for(|&v| v).await;
447            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
448        } => {
449            tracing::warn!("Victauri MCP server shutdown timeout — forcing exit");
450        }
451    }
452    Ok(())
453}
454
455async fn try_bind(preferred: u16) -> anyhow::Result<(tokio::net::TcpListener, u16)> {
456    if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{preferred}")).await {
457        return Ok((listener, preferred));
458    }
459
460    for offset in 1..=PORT_FALLBACK_RANGE {
461        // Saturating/checked add: a `preferred` near u16::MAX (e.g. 65530) would
462        // otherwise overflow `preferred + offset` (panic in debug, wrap in release).
463        let Some(port) = preferred.checked_add(offset) else {
464            break;
465        };
466        if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await {
467            return Ok((listener, port));
468        }
469    }
470
471    anyhow::bail!(
472        "could not bind to any port in range {preferred}-{}",
473        preferred.saturating_add(PORT_FALLBACK_RANGE)
474    )
475}
476
477fn discovery_dir() -> std::path::PathBuf {
478    discovery_root().join(std::process::id().to_string())
479}
480
481/// The directory holding every `<pid>/` discovery entry of this user — decided ONCE per
482/// process, so the entry is written and later removed in the same place.
483///
484/// On Unix it is per-user: `$XDG_RUNTIME_DIR/victauri` when that directory is private to us,
485/// else `<temp>/victauri-<euid>`. The old shared `/tmp/victauri` let any other local user
486/// pre-create it, after which the ownership check refused it and discovery was blocked for
487/// good. `<temp>/victauri-<euid>` is predictable too, and sticky `/tmp` stops us deleting
488/// another user's pre-created copy, so when that root is untrusted (or cannot be created) the
489/// entry goes to a per-user root under the home directory instead —
490/// `$XDG_STATE_HOME/victauri`, else `$HOME/.local/state/victauri` — owner-only (0700) and
491/// verified like the others (R5B-LINDISC1). Windows and other platforms keep `<temp>/victauri`
492/// (their temp dir is per-user; Windows readers verify ownership). Every reader (`victauri`
493/// CLI, `victauri-test`, `victauri-watchdog`, the VS Code extension) scans the same roots, in
494/// order: runtime dir, `<temp>/victauri-<euid>`, the home state root, then the legacy
495/// `<temp>/victauri`.
496fn discovery_root() -> std::path::PathBuf {
497    static ROOT: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
498    ROOT.get_or_init(|| {
499        #[cfg(unix)]
500        {
501            let euid = current_euid();
502            select_discovery_root(
503                &discovery_root_from(std::env::var_os("XDG_RUNTIME_DIR"), euid),
504                euid.and_then(|_| {
505                    home_state_root(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME"))
506                }),
507            )
508        }
509        #[cfg(not(unix))]
510        {
511            std::env::temp_dir().join("victauri")
512        }
513    })
514    .clone()
515}
516
517/// `primary` when it is (or can be made) a private directory of ours; else the home state
518/// `fallback` when THAT can be; else `primary` anyway — whose ownership check then refuses it,
519/// so discovery fails closed exactly as before.
520#[cfg(unix)]
521fn select_discovery_root(
522    primary: &std::path::Path,
523    fallback: Option<std::path::PathBuf>,
524) -> std::path::PathBuf {
525    if ensure_unix_private_dir(primary) {
526        return primary.to_path_buf();
527    }
528    if let Some(fallback) = fallback
529        && ensure_home_state_root(&fallback)
530    {
531        tracing::warn!(
532            "discovery root {} is not usable (another user may have created it); \
533             registering under {} instead",
534            primary.display(),
535            fallback.display()
536        );
537        return fallback;
538    }
539    primary.to_path_buf()
540}
541
542/// The per-user discovery root under the home directory: `$XDG_STATE_HOME/victauri`, else
543/// `$HOME/.local/state/victauri` (each only when absolute).
544#[cfg(unix)]
545fn home_state_root(
546    xdg_state_home: Option<std::ffi::OsString>,
547    home: Option<std::ffi::OsString>,
548) -> Option<std::path::PathBuf> {
549    let absolute =
550        |p: std::ffi::OsString| Some(std::path::PathBuf::from(p)).filter(|p| p.is_absolute());
551    xdg_state_home
552        .and_then(absolute)
553        .or_else(|| {
554            home.and_then(absolute)
555                .map(|h| h.join(".local").join("state"))
556        })
557        .map(|state| state.join("victauri"))
558}
559
560/// Create (0700) or accept the home state `root`: its parent must be a directory owned by us
561/// that no OTHER user can write (so nobody else can swap `victauri` out from under us), and
562/// `root` itself a private directory of ours — the same check every other root gets.
563#[cfg(unix)]
564fn ensure_home_state_root(root: &std::path::Path) -> bool {
565    use std::os::unix::fs::{DirBuilderExt, MetadataExt};
566
567    let (Some(parent), Some(euid)) = (root.parent(), current_euid()) else {
568        return false;
569    };
570    // Missing `~/.local/state` components are created owner-only.
571    if std::fs::DirBuilder::new()
572        .recursive(true)
573        .mode(0o700)
574        .create(parent)
575        .is_err()
576    {
577        return false;
578    }
579    let parent_ok = std::fs::metadata(parent)
580        .is_ok_and(|m| m.is_dir() && m.uid() == euid && m.mode() & 0o002 == 0);
581    parent_ok && ensure_unix_private_dir(root)
582}
583
584#[cfg(unix)]
585fn discovery_root_from(
586    xdg_runtime_dir: Option<std::ffi::OsString>,
587    euid: Option<u32>,
588) -> std::path::PathBuf {
589    use std::os::unix::fs::{MetadataExt, PermissionsExt};
590    let Some(euid) = euid else {
591        // Unknown uid: the legacy path, which the ownership checks then refuse (fail closed).
592        return std::env::temp_dir().join("victauri");
593    };
594    let private_runtime_dir = xdg_runtime_dir
595        .map(std::path::PathBuf::from)
596        .filter(|dir| dir.is_absolute())
597        .filter(|dir| {
598            std::fs::symlink_metadata(dir).is_ok_and(|m| {
599                m.file_type().is_dir() && m.uid() == euid && m.permissions().mode() & 0o077 == 0
600            })
601        });
602    private_runtime_dir.map_or_else(
603        || std::env::temp_dir().join(format!("victauri-{euid}")),
604        |dir| dir.join("victauri"),
605    )
606}
607
608#[cfg(unix)]
609fn current_euid() -> Option<u32> {
610    current_euid_in(&std::env::temp_dir())
611}
612
613#[cfg(unix)]
614fn current_euid_in(dir: &std::path::Path) -> Option<u32> {
615    use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
616
617    // An UNPREDICTABLE name (R4-DISC2): `<pid>_<sequence>` in the shared temp dir let another
618    // local user pre-plant every name we would try, making this fail — which disables
619    // discovery (the callers fail closed on an unknown uid).
620    for _ in 0..16 {
621        let probe = dir.join(format!(
622            ".victauri_plugin_uidprobe_{}",
623            uuid::Uuid::new_v4().simple()
624        ));
625        let file = std::fs::OpenOptions::new()
626            .write(true)
627            .create_new(true)
628            .mode(0o600)
629            .open(&probe)
630            .ok();
631        if let Some(file) = file {
632            let uid = file.metadata().ok().map(|m| m.uid());
633            drop(file);
634            let _ = std::fs::remove_file(probe);
635            if uid.is_some() {
636                return uid;
637            }
638        }
639    }
640    None
641}
642
643#[cfg(unix)]
644fn ensure_unix_private_dir(path: &std::path::Path) -> bool {
645    use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
646
647    let Some(euid) = current_euid() else {
648        return false;
649    };
650    match std::fs::symlink_metadata(path) {
651        Ok(meta) => {
652            if !meta.file_type().is_dir() || meta.uid() != euid {
653                return false;
654            }
655            if std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).is_err() {
656                return false;
657            }
658        }
659        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
660            let mut builder = std::fs::DirBuilder::new();
661            builder.mode(0o700);
662            if builder.create(path).is_err() {
663                return false;
664            }
665        }
666        Err(_) => return false,
667    }
668    unix_private_dir_is_trusted(path)
669}
670
671#[cfg(unix)]
672fn unix_private_dir_is_trusted(path: &std::path::Path) -> bool {
673    use std::os::unix::fs::{MetadataExt, PermissionsExt};
674
675    let Some(euid) = current_euid() else {
676        return false;
677    };
678    std::fs::symlink_metadata(path).is_ok_and(|meta| {
679        meta.file_type().is_dir() && meta.uid() == euid && (meta.permissions().mode() & 0o077) == 0
680    })
681}
682
683/// Restrict a file or directory to current-user-only access on Windows via `icacls`.
684#[cfg(windows)]
685#[allow(unsafe_code)]
686fn current_windows_username() -> Option<String> {
687    use windows::Win32::System::WindowsProgramming::GetUserNameW;
688    use windows::core::PWSTR;
689
690    let mut buffer = [0_u16; 257];
691    let mut len = buffer.len() as u32;
692    // SAFETY: `buffer` is writable for `len` UTF-16 code units and remains alive
693    // for the duration of the call. `GetUserNameW` writes at most that capacity.
694    unsafe {
695        GetUserNameW(Some(PWSTR(buffer.as_mut_ptr())), &raw mut len).ok()?;
696    }
697    let end = buffer
698        .iter()
699        .position(|unit| *unit == 0)
700        .unwrap_or(len as usize);
701    String::from_utf16(&buffer[..end])
702        .ok()
703        .filter(|name| !name.is_empty())
704}
705
706/// NUL-terminated UTF-16 encoding of a path for the Win32 `*W` APIs.
707#[cfg(windows)]
708fn to_wide(path: &std::path::Path) -> Vec<u16> {
709    use std::os::windows::ffi::OsStrExt;
710    path.as_os_str().encode_wide().chain(Some(0)).collect()
711}
712
713/// A standalone, owned copy of the current process user's SID.
714///
715/// `GetTokenInformation` returns a `TOKEN_USER` whose `Sid` pointer aliases into the
716/// token-info buffer; we copy the SID bytes out so the value is self-contained and the
717/// pointer stays valid for the lifetime of this struct.
718#[cfg(windows)]
719struct OwnedSid(Vec<u8>);
720
721#[cfg(windows)]
722impl OwnedSid {
723    fn as_psid(&self) -> windows::Win32::Security::PSID {
724        windows::Win32::Security::PSID(self.0.as_ptr() as *mut core::ffi::c_void)
725    }
726}
727
728/// Copy the SID from a token-information class into an owned buffer.
729///
730/// Used for `TokenUser` (the account SID) and `TokenOwner` (the SID that *owns objects
731/// this process creates*). Both `TOKEN_USER` (`.User.Sid`) and `TOKEN_OWNER` (`.Owner`)
732/// lead with the `PSID` at offset 0, so the SID pointer is read from the start of the
733/// returned buffer.
734#[cfg(windows)]
735#[allow(unsafe_code)]
736fn token_sid(class: windows::Win32::Security::TOKEN_INFORMATION_CLASS) -> Option<OwnedSid> {
737    use windows::Win32::Foundation::{CloseHandle, HANDLE};
738    use windows::Win32::Security::{GetLengthSid, GetTokenInformation, PSID, TOKEN_QUERY};
739    use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
740
741    struct TokenGuard(HANDLE);
742    impl Drop for TokenGuard {
743        fn drop(&mut self) {
744            // SAFETY: `self.0` came from `OpenProcessToken` and is closed exactly once.
745            unsafe {
746                let _ = CloseHandle(self.0);
747            }
748        }
749    }
750
751    let mut token = HANDLE::default();
752    // SAFETY: `GetCurrentProcess` returns a pseudo-handle valid for the call; `token` is a
753    // writable out-param. On success it owns a real handle, closed by `TokenGuard` below.
754    unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token).ok()? };
755    let _guard = TokenGuard(token);
756
757    let mut len = 0_u32;
758    // SAFETY: size probe — a null buffer with len 0 makes the call write the required size
759    // into `len` and fail with ERROR_INSUFFICIENT_BUFFER (ignored; we only want `len`).
760    unsafe {
761        let _ = GetTokenInformation(token, class, None, 0, &raw mut len);
762    }
763    if len == 0 {
764        return None;
765    }
766    let mut buf = vec![0_u8; len as usize];
767    // SAFETY: `buf` is writable for `len` bytes; on success it holds the requested struct.
768    unsafe {
769        GetTokenInformation(
770            token,
771            class,
772            Some(buf.as_mut_ptr().cast::<core::ffi::c_void>()),
773            len,
774            &raw mut len,
775        )
776        .ok()?;
777    }
778    // SAFETY: both `TOKEN_USER` and `TOKEN_OWNER` lead with the `PSID` at offset 0, so the
779    // SID pointer is the first pointer-sized field of `buf`.
780    let sid_ptr = unsafe { *buf.as_ptr().cast::<PSID>() };
781    // SAFETY: `sid_ptr` points to a valid SID within `buf`.
782    let sid_len = unsafe { GetLengthSid(sid_ptr) };
783    if sid_len == 0 {
784        return None;
785    }
786    let mut sid = vec![0_u8; sid_len as usize];
787    // SAFETY: `sid_ptr` is valid for `sid_len` bytes (per `GetLengthSid`); `sid` has capacity.
788    unsafe {
789        core::ptr::copy_nonoverlapping(sid_ptr.0.cast::<u8>(), sid.as_mut_ptr(), sid_len as usize);
790    }
791    Some(OwnedSid(sid))
792}
793
794/// SIDs that legitimately own a directory *this* process creates: the token USER and the
795/// token's default OWNER. They are identical for a normal user, but an **elevated** admin
796/// token's default owner is the `BUILTIN\Administrators` group — so objects an elevated
797/// process creates are owned by that group, not the user. Accepting either is what makes
798/// the ownership check correct under elevation (where it would otherwise reject every
799/// directory we create and break discovery entirely).
800///
801/// Also `BUILTIN\Administrators` when this account is a member of it (enabled or deny-only):
802/// an app started from an elevated PowerShell/cmd creates the discovery root owned by that
803/// group, while a later app of the SAME user started from Git Bash (whose runtime sets the
804/// default owner to the user) or unelevated has only the user as owner — it refused the root
805/// and silently never registered for discovery until the directory was deleted by hand
806/// (round 4, reproduced on this machine). `%TEMP%` is per-user, and every member of that group
807/// can already read everything in it, so accepting the group concedes nothing.
808#[cfg(windows)]
809fn acceptable_owner_sids() -> Vec<OwnedSid> {
810    use windows::Win32::Security::{TokenOwner, TokenUser};
811    let mut sids: Vec<OwnedSid> = [TokenUser, TokenOwner]
812        .into_iter()
813        .filter_map(token_sid)
814        .collect();
815    sids.extend(builtin_administrators_sid_if_member());
816    sids
817}
818
819/// The `BUILTIN\Administrators` SID when the current token lists it among its groups, with
820/// any attributes (an unelevated admin holds it deny-only; it is still this account's group).
821#[cfg(windows)]
822#[allow(unsafe_code)]
823fn builtin_administrators_sid_if_member() -> Option<OwnedSid> {
824    use windows::Win32::Foundation::{CloseHandle, HANDLE};
825    use windows::Win32::Security::{
826        CreateWellKnownSid, EqualSid, GetTokenInformation, PSID, SECURITY_MAX_SID_SIZE,
827        TOKEN_GROUPS, TOKEN_QUERY, TokenGroups, WinBuiltinAdministratorsSid,
828    };
829    use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
830
831    struct TokenGuard(HANDLE);
832    impl Drop for TokenGuard {
833        fn drop(&mut self) {
834            // SAFETY: `self.0` came from `OpenProcessToken` and is closed exactly once.
835            unsafe {
836                let _ = CloseHandle(self.0);
837            }
838        }
839    }
840
841    let mut admins = vec![0_u8; SECURITY_MAX_SID_SIZE as usize];
842    let mut admins_len = SECURITY_MAX_SID_SIZE;
843    // SAFETY: `admins` is writable for `admins_len` bytes, the documented maximum SID size.
844    unsafe {
845        CreateWellKnownSid(
846            WinBuiltinAdministratorsSid,
847            None,
848            Some(PSID(admins.as_mut_ptr().cast::<core::ffi::c_void>())),
849            &raw mut admins_len,
850        )
851        .ok()?;
852    }
853    admins.truncate(admins_len as usize);
854    let admins = OwnedSid(admins);
855
856    let mut token = HANDLE::default();
857    // SAFETY: pseudo-handle for the current process; `token` is a writable out-param, closed by
858    // `TokenGuard` on success.
859    unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token).ok()? };
860    let _guard = TokenGuard(token);
861    let mut len = 0_u32;
862    // SAFETY: size probe (null buffer); fails with ERROR_INSUFFICIENT_BUFFER, setting `len`.
863    unsafe {
864        let _ = GetTokenInformation(token, TokenGroups, None, 0, &raw mut len);
865    }
866    if (len as usize) < std::mem::size_of::<TOKEN_GROUPS>() {
867        return None;
868    }
869    // u64-backed so the TOKEN_GROUPS header (u32 count + pointer-aligned array) is aligned.
870    let mut buf = vec![0_u64; (len as usize).div_ceil(8)];
871    // SAFETY: `buf` is writable for at least `len` bytes; on success it holds a TOKEN_GROUPS.
872    unsafe {
873        GetTokenInformation(
874            token,
875            TokenGroups,
876            Some(buf.as_mut_ptr().cast::<core::ffi::c_void>()),
877            len,
878            &raw mut len,
879        )
880        .ok()?;
881    }
882    // SAFETY: `buf` holds a TOKEN_GROUPS whose `Groups` array has `GroupCount` entries, all
883    // within the buffer the call filled.
884    let member = unsafe {
885        let groups = &*buf.as_ptr().cast::<TOKEN_GROUPS>();
886        std::slice::from_raw_parts(groups.Groups.as_ptr(), groups.GroupCount as usize)
887            .iter()
888            .any(|g| EqualSid(g.Sid, admins.as_psid()).is_ok())
889    };
890    member.then_some(admins)
891}
892
893/// True iff `path` exists and its owner SID is one this process would create objects as
894/// (its token user, or — under elevation — its token's default owner group).
895///
896/// This is the Windows counterpart to the Unix uid check: it refuses a discovery
897/// directory an attacker pre-created on a shared TEMP (the attacker would be its owner),
898/// closing the PID-preplant vector before any token is trusted.
899#[cfg(windows)]
900#[allow(unsafe_code)]
901fn dir_owned_by_current_user(path: &std::path::Path) -> bool {
902    use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
903    use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
904    use windows::Win32::Security::{
905        EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
906    };
907    use windows::core::PCWSTR;
908
909    let acceptable = acceptable_owner_sids();
910    if acceptable.is_empty() {
911        return false;
912    }
913    let wide = to_wide(path);
914    let mut owner = PSID::default();
915    let mut psd = PSECURITY_DESCRIPTOR::default();
916    // SAFETY: `wide` is a NUL-terminated path; we request OWNER info only; `owner` aliases
917    // into `psd`, which the OS allocates and we free with `LocalFree` below.
918    let rc = unsafe {
919        GetNamedSecurityInfoW(
920            PCWSTR(wide.as_ptr()),
921            SE_FILE_OBJECT,
922            OWNER_SECURITY_INFORMATION,
923            Some(&raw mut owner),
924            None,
925            None,
926            None,
927            &raw mut psd,
928        )
929    };
930    if rc != ERROR_SUCCESS {
931        return false;
932    }
933    // SAFETY: `owner` (within `psd`) and each `sid` are valid SIDs for the comparison.
934    let owned = acceptable
935        .iter()
936        .any(|sid| unsafe { EqualSid(owner, sid.as_psid()).is_ok() });
937    // SAFETY: `psd` was allocated by `GetNamedSecurityInfoW`; freed exactly once.
938    unsafe {
939        let _ = LocalFree(Some(HLOCAL(psd.0)));
940    }
941    owned
942}
943
944/// Replace `path`'s DACL with a PROTECTED, owner-only DACL (current user: full control,
945/// inherited by children).
946///
947/// Unlike `icacls /inheritance:r /grant:r` — which strips only inherited ACEs and replaces
948/// only the owner's grant, leaving any pre-planted explicit ACE for another principal
949/// (e.g. `BUILTIN\Guests`) intact — this rebuilds the DACL from scratch and marks it
950/// PROTECTED, so NO inherited or pre-existing explicit ACE survives. Returns true on
951/// success.
952#[cfg(windows)]
953#[allow(unsafe_code)]
954fn apply_owner_only_dacl(path: &std::path::Path) -> bool {
955    use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
956    use windows::Win32::Security::Authorization::{
957        EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW,
958        SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
959    };
960    use windows::Win32::Security::{
961        ACE_FLAGS, ACL, DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION,
962    };
963    use windows::core::PWSTR;
964
965    use windows::Win32::Security::TokenUser;
966
967    // Full control (GENERIC_ALL) granted to the owner; inherited by sub-containers/objects.
968    const GENERIC_ALL_RIGHTS: u32 = 0x1000_0000;
969    const SUB_CONTAINERS_AND_OBJECTS_INHERIT: u32 = 0x3;
970
971    // Grant the token USER (the running account) full control — even when the directory's
972    // owner is the Administrators group (elevated), the running user retains access.
973    let Some(me) = token_sid(TokenUser) else {
974        return false;
975    };
976
977    let explicit = EXPLICIT_ACCESS_W {
978        grfAccessPermissions: GENERIC_ALL_RIGHTS,
979        grfAccessMode: SET_ACCESS,
980        grfInheritance: ACE_FLAGS(SUB_CONTAINERS_AND_OBJECTS_INHERIT),
981        Trustee: TRUSTEE_W {
982            pMultipleTrustee: core::ptr::null_mut(),
983            MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE,
984            TrusteeForm: TRUSTEE_IS_SID,
985            TrusteeType: TRUSTEE_IS_USER,
986            ptstrName: PWSTR(me.as_psid().0.cast::<u16>()),
987        },
988    };
989
990    let mut new_acl: *mut ACL = core::ptr::null_mut();
991    // SAFETY: one explicit entry, no prior ACL; on success `new_acl` is a LocalAlloc'd ACL
992    // that we free with `LocalFree` below.
993    let rc = unsafe { SetEntriesInAclW(Some(&[explicit]), None, &raw mut new_acl) };
994    if rc != ERROR_SUCCESS || new_acl.is_null() {
995        return false;
996    }
997
998    let mut wide = to_wide(path);
999    // SAFETY: `wide` is a NUL-terminated mutable path; `new_acl` is a valid ACL. PROTECTED
1000    // strips inheritance and any other explicit ACE, leaving exactly the owner-only DACL.
1001    let set_rc = unsafe {
1002        SetNamedSecurityInfoW(
1003            PWSTR(wide.as_mut_ptr()),
1004            SE_FILE_OBJECT,
1005            DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
1006            None,
1007            None,
1008            Some(new_acl),
1009            None,
1010        )
1011    };
1012    // SAFETY: `new_acl` came from `SetEntriesInAclW`; freed exactly once.
1013    unsafe {
1014        let _ = LocalFree(Some(HLOCAL(new_acl.cast::<core::ffi::c_void>())));
1015    }
1016    set_rc == ERROR_SUCCESS
1017}
1018
1019/// Best-effort `icacls` fallback used only if the Win32 DACL replacement fails (e.g. an
1020/// unusual filesystem). Strips inherited + common world/group principals and grants the
1021/// owner. Weaker than `apply_owner_only_dacl` (a custom-SID pre-plant could survive), so
1022/// it runs only when the robust path is unavailable.
1023#[cfg(windows)]
1024fn icacls_restrict_to_current_user(path: &std::path::Path) -> bool {
1025    let Some(username) = current_windows_username() else {
1026        return false;
1027    };
1028    let path_str = path.to_string_lossy();
1029    std::process::Command::new("icacls")
1030        .args([
1031            &*path_str,
1032            "/inheritance:r",
1033            "/remove",
1034            "*S-1-1-0",
1035            "*S-1-5-32-545",
1036            "*S-1-5-11",
1037            "/grant:r",
1038            &format!("{username}:F"),
1039            "/q",
1040        ])
1041        .stdin(std::process::Stdio::null())
1042        .stdout(std::process::Stdio::null())
1043        .stderr(std::process::Stdio::null())
1044        .status()
1045        .is_ok_and(|status| status.success())
1046}
1047
1048/// Lock `path` down to owner-only access. Robust path first (PROTECTED owner-only DACL via
1049/// the Win32 security API), falling back to `icacls` only if that fails — fail-closed
1050/// (a `false` return makes the caller refuse and remove the directory).
1051#[cfg(windows)]
1052fn restrict_to_current_user(path: &std::path::Path) -> bool {
1053    if apply_owner_only_dacl(path) {
1054        return true;
1055    }
1056    tracing::warn!(
1057        "owner-only DACL apply failed for {}; falling back to icacls",
1058        path.display()
1059    );
1060    icacls_restrict_to_current_user(path)
1061}
1062
1063#[cfg(windows)]
1064fn ensure_windows_private_dir(path: &std::path::Path, remove_on_acl_failure: bool) -> bool {
1065    let mut created = false;
1066    match std::fs::symlink_metadata(path) {
1067        Ok(meta) => {
1068            if !meta.file_type().is_dir() {
1069                return false;
1070            }
1071        }
1072        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1073            if std::fs::create_dir(path).is_err() {
1074                return false;
1075            }
1076            created = true;
1077        }
1078        Err(_) => return false,
1079    }
1080
1081    if !dir_owned_by_current_user(path) {
1082        tracing::warn!(
1083            "refusing discovery directory not owned by current user: {}",
1084            path.display()
1085        );
1086        if created || remove_on_acl_failure {
1087            let _ = std::fs::remove_dir_all(path);
1088        }
1089        return false;
1090    }
1091
1092    if !restrict_to_current_user(path) {
1093        if created || remove_on_acl_failure {
1094            let _ = std::fs::remove_dir_all(path);
1095        }
1096        return false;
1097    }
1098
1099    true
1100}
1101
1102/// Trust the discovery path only when both the shared root and PID directory are owned
1103/// by this process's effective user. Refuse planted paths instead of deleting them.
1104fn ensure_private_dir(dir: &std::path::Path) -> bool {
1105    #[cfg(unix)]
1106    {
1107        let Some(root) = dir.parent() else {
1108            return false;
1109        };
1110        if !ensure_unix_private_dir(root) || !ensure_unix_private_dir(dir) {
1111            tracing::warn!("refusing untrusted discovery path {}", dir.display());
1112            return false;
1113        }
1114    }
1115    #[cfg(windows)]
1116    {
1117        let Some(root) = dir.parent() else {
1118            return false;
1119        };
1120        // Refuse an untrusted shared root BEFORE creating the PID directory. Otherwise an
1121        // attacker-controlled root could rename/swap the child directory between our checks.
1122        if !ensure_windows_private_dir(root, false) {
1123            tracing::warn!("refusing untrusted discovery root {}", root.display());
1124            return false;
1125        }
1126        // Refuse a directory we don't own — on a shared TEMP an attacker who pre-created
1127        // our PID dir would be its owner. Mirrors the Unix uid check; defeats PID-preplant
1128        // before any token is written/trusted. (A dir WE just created we own, so this
1129        // passes for the normal path.)
1130        if !ensure_windows_private_dir(dir, true) {
1131            tracing::warn!("refusing untrusted discovery path {}", dir.display());
1132            return false;
1133        }
1134    }
1135    #[cfg(all(not(unix), not(windows)))]
1136    if std::fs::create_dir_all(dir).is_err() {
1137        return false;
1138    }
1139    true
1140}
1141
1142/// Write `contents` to `path` as a fresh, user-only file. Uses exclusive
1143/// (`create_new` / `O_EXCL`) creation so a pre-planted file OR symlink at `path`
1144/// is refused rather than written through, and sets `0600` at creation on Unix so
1145/// there is no window where the file exists with default-umask permissions.
1146fn write_private_file(path: &std::path::Path, contents: &str) {
1147    // Clear any stale/pre-planted entry (symlink-aware) so our exclusive create
1148    // succeeds for a fresh file; a symlink racing in afterwards is refused by
1149    // `create_new` (O_EXCL treats a final-component symlink as "exists").
1150    if std::fs::symlink_metadata(path).is_ok() {
1151        let _ = std::fs::remove_file(path);
1152    }
1153    #[cfg(unix)]
1154    let result = {
1155        use std::io::Write;
1156        use std::os::unix::fs::OpenOptionsExt;
1157        std::fs::OpenOptions::new()
1158            .write(true)
1159            .create_new(true)
1160            .mode(0o600)
1161            .open(path)
1162            .and_then(|mut f| f.write_all(contents.as_bytes()))
1163    };
1164    #[cfg(not(unix))]
1165    let result = {
1166        use std::io::Write;
1167        std::fs::OpenOptions::new()
1168            .write(true)
1169            .create_new(true)
1170            .open(path)
1171            .and_then(|mut f| f.write_all(contents.as_bytes()))
1172    };
1173    // Report a write failure; on Windows additionally lock the new file down to the
1174    // current user and remove it if the ACL cannot be applied (never leave a discovery
1175    // file world-readable). Split per-platform so neither config trips `-D warnings`:
1176    // the Windows-only post-step would otherwise make an early `return` needless on Unix.
1177    #[cfg(windows)]
1178    match result {
1179        Ok(()) => {
1180            if !restrict_to_current_user(path) {
1181                let _ = std::fs::remove_file(path);
1182                tracing::warn!("could not restrict discovery file {}", path.display());
1183            }
1184        }
1185        Err(e) => {
1186            tracing::debug!("could not write discovery file {}: {e}", path.display());
1187        }
1188    }
1189    #[cfg(not(windows))]
1190    if let Err(e) = result {
1191        tracing::debug!("could not write discovery file {}: {e}", path.display());
1192    }
1193}
1194
1195fn write_port_file(port: u16, identifier: Option<&str>, product_name: Option<&str>) {
1196    let dir = discovery_dir();
1197    if !ensure_private_dir(&dir) {
1198        return;
1199    }
1200    write_private_file(&dir.join("port"), &port.to_string());
1201    // Write metadata for multi-server discovery. The app `identifier` lets a discovery
1202    // client (e.g. `victauri bridge --app <id>`) select the RIGHT app when several Victauri
1203    // instances are running, instead of guessing — the root cause of agents binding to the
1204    // wrong process on a shared port.
1205    let metadata = serde_json::json!({
1206        "pid": std::process::id(),
1207        "port": port,
1208        "identifier": identifier,
1209        "product_name": product_name,
1210        "started_at": chrono::Utc::now().to_rfc3339(),
1211        "version": env!("CARGO_PKG_VERSION"),
1212    });
1213    write_private_file(&dir.join("metadata.json"), &metadata.to_string());
1214}
1215
1216fn write_token_file(token: &str) {
1217    let dir = discovery_dir();
1218    if !ensure_private_dir(&dir) {
1219        return;
1220    }
1221    write_private_file(&dir.join("token"), token);
1222}
1223
1224fn remove_port_file() {
1225    let dir = discovery_dir();
1226    #[cfg(unix)]
1227    {
1228        let Some(root) = dir.parent() else {
1229            return;
1230        };
1231        if !unix_private_dir_is_trusted(root) || !unix_private_dir_is_trusted(&dir) {
1232            return;
1233        }
1234    }
1235    let _ = std::fs::remove_dir_all(dir);
1236}
1237
1238/// Parse a single bridge event JSON value into an [`AppEvent`](victauri_core::AppEvent).
1239///
1240/// Returns `None` for unrecognised event types, allowing callers to skip them.
1241#[must_use]
1242pub fn parse_bridge_event(ev: &serde_json::Value) -> Option<victauri_core::AppEvent> {
1243    parse_bridge_event_from(ev, DEFAULT_WEBVIEW_LABEL)
1244}
1245
1246/// [`parse_bridge_event`] for an event drained from the webview `label`. Uses the event's own
1247/// JS timestamp (epoch ms) when present — not the drain time, which lags by up to the drain
1248/// interval and collapses a burst of events onto one instant.
1249pub(super) fn parse_bridge_event_from(
1250    ev: &serde_json::Value,
1251    label: &str,
1252) -> Option<victauri_core::AppEvent> {
1253    use chrono::{TimeZone, Utc};
1254    use victauri_core::AppEvent;
1255
1256    let event_type = ev.get("type").and_then(|t| t.as_str()).unwrap_or("");
1257    #[allow(clippy::cast_possible_truncation)]
1258    let now = ev
1259        .get("timestamp")
1260        .and_then(serde_json::Value::as_f64)
1261        .filter(|ms| ms.is_finite() && *ms > 0.0)
1262        .and_then(|ms| Utc.timestamp_millis_opt(ms as i64).single())
1263        .unwrap_or_else(Utc::now);
1264
1265    let app_event = match event_type {
1266        "console" => AppEvent::console(
1267            ev.get("level")
1268                .and_then(|l| l.as_str())
1269                .unwrap_or("log")
1270                .to_string(),
1271            ev.get("message")
1272                .and_then(|m| m.as_str())
1273                .unwrap_or("")
1274                .to_string(),
1275            now,
1276        ),
1277        "dom_mutation" => AppEvent::dom_mutation(
1278            label.to_string(),
1279            now,
1280            ev.get("count")
1281                .and_then(serde_json::Value::as_u64)
1282                .unwrap_or(0) as u32,
1283        ),
1284        "ipc" => {
1285            let cmd = ev
1286                .get("command")
1287                .and_then(|c| c.as_str())
1288                .unwrap_or("unknown");
1289            AppEvent::Ipc(
1290                victauri_core::IpcCall::new(
1291                    uuid::Uuid::new_v4().to_string(),
1292                    cmd.to_string(),
1293                    now,
1294                    match ev.get("status").and_then(|s| s.as_str()) {
1295                        Some("ok") => victauri_core::IpcResult::Ok(serde_json::Value::Null),
1296                        Some("error") => victauri_core::IpcResult::Err("error".to_string()),
1297                        _ => victauri_core::IpcResult::Pending,
1298                    },
1299                    ev.get("duration_ms")
1300                        .and_then(serde_json::Value::as_f64)
1301                        .map(|d| d as u64),
1302                    ev.get("arg_size_bytes")
1303                        .and_then(serde_json::Value::as_u64)
1304                        .map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX)),
1305                    label.to_string(),
1306                )
1307                .with_mocked(ev.get("mocked").and_then(serde_json::Value::as_bool) == Some(true))
1308                // Only the one known non-default transport is accepted: the value is page
1309                // data, so it is never stored verbatim.
1310                .with_transport(
1311                    (ev.get("transport").and_then(|t| t.as_str()) == Some("post_message"))
1312                        .then(|| "post_message".to_string()),
1313                ),
1314            )
1315        }
1316        "network" => AppEvent::state_change(
1317            format!(
1318                "network.{}",
1319                ev.get("method").and_then(|m| m.as_str()).unwrap_or("GET")
1320            ),
1321            now,
1322            ev.get("url")
1323                .and_then(|u| u.as_str())
1324                .map(std::string::ToString::to_string),
1325        ),
1326        "navigation" => AppEvent::window_event(
1327            label.to_string(),
1328            format!(
1329                "navigation.{}",
1330                ev.get("nav_type")
1331                    .and_then(|n| n.as_str())
1332                    .unwrap_or("unknown")
1333            ),
1334            now,
1335        ),
1336        "dom_interaction" => {
1337            let action_str = ev.get("action").and_then(|a| a.as_str()).unwrap_or("click");
1338            let action = match action_str {
1339                "click" => victauri_core::InteractionKind::Click,
1340                "double_click" => victauri_core::InteractionKind::DoubleClick,
1341                "fill" => victauri_core::InteractionKind::Fill,
1342                "key_press" => victauri_core::InteractionKind::KeyPress,
1343                "select" => victauri_core::InteractionKind::Select,
1344                "navigate" => victauri_core::InteractionKind::Navigate,
1345                "scroll" => victauri_core::InteractionKind::Scroll,
1346                _ => victauri_core::InteractionKind::Click,
1347            };
1348            AppEvent::dom_interaction(
1349                action,
1350                ev.get("selector")
1351                    .and_then(|s| s.as_str())
1352                    .unwrap_or("body")
1353                    .to_string(),
1354                ev.get("value")
1355                    .and_then(|v| v.as_str())
1356                    .map(std::string::ToString::to_string),
1357                now,
1358                label.to_string(),
1359            )
1360        }
1361        _ => return None,
1362    };
1363
1364    Some(app_event)
1365}
1366
1367async fn event_drain_loop(
1368    state: Arc<VictauriState>,
1369    bridge: Arc<dyn WebviewBridge>,
1370    mut shutdown: tokio::sync::watch::Receiver<bool>,
1371) {
1372    // Per-window high-water marks live in `state.drain_watermarks` (shared with
1373    // `recording flush`), one per label, so every live window is drained.
1374    loop {
1375        tokio::select! {
1376            _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
1377            _ = shutdown.changed() => break,
1378        }
1379
1380        // Only drain while a time-travel recording is active. Draining evals
1381        // `drainEvents` in EVERY window every second, and each eval injects JS that
1382        // calls back via `victauri_eval_callback` — an IPC request. That constant
1383        // background IPC churn (for a 3-window app: ~3 callbacks/sec, forever) AMPLIFIES a
1384        // Tauri-runtime `Rc<Webview>` use-after-free that fires when an IPC request hits
1385        // `ipc::protocol::get` *during a webview reload* (HMR / navigation). The recorder
1386        // is the only consumer that needs the continuous stream; when nothing is recording,
1387        // idle draining is pure crash-amplifying churn (it was the dominant amplifier behind
1388        // the 0.8.0/0.8.1 host crash). `explain`/`event_bus` drain on demand instead.
1389        // See CHANGELOG 0.8.2.
1390        if !state.recorder.is_recording() {
1391            continue;
1392        }
1393
1394        let labels = bridge.list_window_labels();
1395        if labels.is_empty() {
1396            continue;
1397        }
1398        // Drop watermarks for windows that have closed so the map can't grow
1399        // unbounded across many ephemeral windows.
1400        state.drain_watermarks.retain(&labels);
1401
1402        // Drain all windows concurrently. A blind window (e.g. one missing the
1403        // `victauri:default` capability) hangs until the 5s eval timeout; draining
1404        // sequentially would let it stall every other window's drain. Concurrency
1405        // keeps a healthy window's events flowing regardless of a blind sibling.
1406        let mut set = tokio::task::JoinSet::new();
1407        for label in &labels {
1408            let state = Arc::clone(&state);
1409            let bridge = Arc::clone(&bridge);
1410            let label = label.clone();
1411            set.spawn(async move { drain_window_into_recording(&state, &bridge, &label).await });
1412        }
1413        while set.join_next().await.is_some() {}
1414    }
1415}
1416
1417/// Drain one window's new events (everything after its shared drain position) into the event
1418/// log and recorder, then advance the position. Serialized per window, so the background drain
1419/// and `recording flush` never ingest the same window's events twice. Returns how many events
1420/// were recorded, or `None` if the window could not be drained this time (the position is left
1421/// unchanged, so a transient failure just re-reads the same range next time).
1422pub(super) async fn drain_window_into_recording(
1423    state: &Arc<VictauriState>,
1424    bridge: &Arc<dyn WebviewBridge>,
1425    label: &str,
1426) -> Option<usize> {
1427    let lock = state.drain_watermarks.lock_for(label);
1428    let _serialized = lock.lock().await;
1429    // Pin the recording this read is for BEFORE the (slow) read: a drain in flight across
1430    // `recording stop` + `start` used to append the old recording's events to the new one.
1431    let Some(generation) = state.recorder.generation() else {
1432        return Some(0);
1433    };
1434    let cursor = state.drain_watermarks.cursor(label);
1435    if cursor.epoch != generation {
1436        // The recording has started but its drain epoch is not reset yet (start and reset are
1437        // two steps); reading now would use the previous recording's positions. The next tick
1438        // reads it.
1439        return Some(0);
1440    }
1441    let (mark, recorded) = drain_window(state, bridge, label, &cursor, generation).await?;
1442    state.drain_watermarks.advance(label, cursor.epoch, mark);
1443    Some(recorded)
1444}
1445
1446/// Read one window's event stream after `cursor` into the event log / recorder (only while
1447/// recording `generation` is still the active one). Returns the new drain position and how many
1448/// events were recorded, or `None` if nothing was drained (pending-eval saturation,
1449/// eval-injection failure, callback timeout, or an unparseable reply).
1450async fn drain_window(
1451    state: &Arc<VictauriState>,
1452    bridge: &Arc<dyn WebviewBridge>,
1453    label: &str,
1454    cursor: &crate::introspection::DrainCursor,
1455    generation: u64,
1456) -> Option<(crate::introspection::DrainMark, usize)> {
1457    // Only a window's first read in a recording skips pre-recording history by timestamp;
1458    // later reads are purely by sequence (a page clock that is off never drops or repeats).
1459    let (after_seq, instance_js, floor_ms) = match &cursor.mark {
1460        Some(m) => (m.seq, super::helpers::js_string(&m.instance), 0.0),
1461        None => (0, "null".to_string(), cursor.floor_ms),
1462    };
1463    let id = uuid::Uuid::new_v4().to_string();
1464    let (tx, rx) = tokio::sync::oneshot::channel();
1465
1466    // Released however this ends — including the drain future being dropped mid-wait.
1467    let _slot = {
1468        let mut pending = state.pending_evals.lock().await;
1469        if pending.len() >= MAX_PENDING_EVALS {
1470            return None;
1471        }
1472        let slot = crate::PendingSlot::insert(&state.pending_evals, &mut pending, id.clone(), tx);
1473        slot.bind_window(Some(label));
1474        slot
1475    };
1476
1477    // Delivered through the bridge's frozen `_evalSettle`, which serializes with the
1478    // `JSON.stringify` captured at bridge init — not the page's own, which page script can
1479    // replace to forge or break every drained event.
1480    let id_js = super::helpers::js_string(&id);
1481    let inject = format!(
1482        r"
1483        (function() {{
1484            var v = window.__VICTAURI__;
1485            if (!v) return;
1486            var r;
1487            try {{ r = v.drainEvents({after_seq}, {instance_js}, {floor_ms}); }}
1488            catch (e) {{ r = {{ __error: String(e && e.message) }}; }}
1489            v._evalSettle({id_js}, r);
1490        }})();
1491        "
1492    );
1493
1494    if bridge.eval_webview(Some(label), &inject).is_err() {
1495        return None;
1496    }
1497
1498    let Ok(Ok(result)) = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await else {
1499        return None;
1500    };
1501
1502    // Page JSON: a lone surrogate (a truncated emoji, or a hostile `console.log('\ud800')`)
1503    // must not make the whole reply unparseable — that stalled the window's recording.
1504    let reply: serde_json::Value = super::page_json::parse_page_json(&result).ok()?;
1505    let mark = crate::introspection::DrainMark {
1506        instance: reply.get("instance")?.as_str()?.to_string(),
1507        seq: reply.get("seq")?.as_u64()?,
1508    };
1509    let mut recorded = 0usize;
1510    // The position comes from the bridge's counter, not from the entries: an entry that fails
1511    // to parse is skipped without holding the window's recording back.
1512    for ev in reply.get("events")?.as_array()? {
1513        if let Some(app_event) = parse_bridge_event_from(ev, label)
1514            && state
1515                .recorder
1516                .record_event_if(generation, app_event.clone())
1517        {
1518            state.event_log.push(app_event);
1519            recorded += 1;
1520        }
1521    }
1522    Some((mark, recorded))
1523}
1524
1525#[cfg(test)]
1526mod tests {
1527    use super::*;
1528    use victauri_core::{AppEvent, InteractionKind, IpcResult};
1529
1530    /// R4-DISC2: the uid-probe file name (`.victauri_plugin_uidprobe_<pid>_<seq>`) was
1531    /// predictable, so another user sharing `/tmp` could pre-plant it and make `current_euid`
1532    /// fail — which disables discovery entirely (fail closed).
1533    #[cfg(unix)]
1534    #[test]
1535    fn uid_probe_survives_pre_planted_probe_names() {
1536        use std::os::unix::fs::MetadataExt;
1537        let dir = tempfile::tempdir().unwrap();
1538        for seq in 0..4096 {
1539            std::fs::create_dir(dir.path().join(format!(
1540                ".victauri_plugin_uidprobe_{}_{seq}",
1541                std::process::id()
1542            )))
1543            .unwrap();
1544        }
1545        let expected = std::fs::metadata(dir.path()).unwrap().uid();
1546        assert_eq!(current_euid_in(dir.path()), Some(expected));
1547        // The probe cleans up after itself: only the planted entries remain.
1548        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 4096);
1549    }
1550
1551    // Round-4 audit blocker #4: a pre-planted explicit ACE for an arbitrary principal
1552    // (the auditor used BUILTIN\Guests) must NOT survive the discovery-dir hardening.
1553    // Proves the robust owner-only DACL replacement closes the icacls residual.
1554    #[cfg(windows)]
1555    #[test]
1556    fn owner_only_dacl_removes_pre_planted_guests_ace() {
1557        use std::process::Command;
1558        let dir = std::env::temp_dir()
1559            .join("victauri_acl_test")
1560            .join(format!("p{}", std::process::id()));
1561        let _ = std::fs::remove_dir_all(&dir);
1562        std::fs::create_dir_all(&dir).expect("create test dir");
1563
1564        // We just created it, so the ownership guard must accept it (owner == token user,
1565        // or the Administrators group under elevation).
1566        assert!(
1567            dir_owned_by_current_user(&dir),
1568            "a freshly created dir must be recognized as owned by this process"
1569        );
1570
1571        let path_str = dir.to_string_lossy().to_string();
1572
1573        // Pre-plant an inheritable explicit ACE for BUILTIN\Guests (S-1-5-32-546).
1574        let Ok(grant) = Command::new("icacls")
1575            .args([path_str.as_str(), "/grant", "*S-1-5-32-546:(OI)(CI)F", "/q"])
1576            .output()
1577        else {
1578            let _ = std::fs::remove_dir_all(&dir);
1579            return; // icacls unavailable — skip rather than false-fail
1580        };
1581        if !grant.status.success() {
1582            let _ = std::fs::remove_dir_all(&dir);
1583            return; // could not plant the ACE (restricted env) — skip
1584        }
1585
1586        let before = Command::new("icacls")
1587            .arg(path_str.as_str())
1588            .output()
1589            .expect("icacls read");
1590        let before_s = String::from_utf8_lossy(&before.stdout);
1591        assert!(
1592            before_s.contains("Guests"),
1593            "pre-condition: the planted Guests ACE should be visible, got:\n{before_s}"
1594        );
1595
1596        // Apply the robust owner-only DACL replacement.
1597        assert!(
1598            apply_owner_only_dacl(&dir),
1599            "apply_owner_only_dacl must succeed on a directory we own"
1600        );
1601
1602        let after = Command::new("icacls")
1603            .arg(path_str.as_str())
1604            .output()
1605            .expect("icacls read");
1606        let after_s = String::from_utf8_lossy(&after.stdout);
1607        assert!(
1608            !after_s.contains("Guests"),
1609            "the pre-planted Guests ACE must NOT survive the owner-only DACL, got:\n{after_s}"
1610        );
1611
1612        let _ = std::fs::remove_dir_all(&dir);
1613    }
1614
1615    #[test]
1616    fn normalize_auth_token_collapses_empty() {
1617        // Audit B2: an empty/whitespace token must become "no auth", never an
1618        // auth-enabled-but-empty-credential state.
1619        assert_eq!(normalize_auth_token(Some(String::new())), None);
1620        assert_eq!(normalize_auth_token(Some("   ".to_string())), None);
1621        assert_eq!(normalize_auth_token(Some("\t\n".to_string())), None);
1622        // A real token is preserved; explicit None stays None.
1623        assert_eq!(
1624            normalize_auth_token(Some("secret-123".to_string())).as_deref(),
1625            Some("secret-123")
1626        );
1627        assert_eq!(normalize_auth_token(None), None);
1628    }
1629
1630    #[tokio::test]
1631    async fn try_bind_preferred_port_available() {
1632        let (listener, port) = try_bind(0).await.unwrap();
1633        let addr = listener.local_addr().unwrap();
1634        assert_eq!(port, 0);
1635        assert_ne!(addr.port(), 0); // OS assigned a real port
1636    }
1637
1638    #[tokio::test]
1639    async fn try_bind_falls_back_when_taken() {
1640        let blocker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1641        let blocked_port = blocker.local_addr().unwrap().port();
1642
1643        let (_, actual) = try_bind(blocked_port).await.unwrap();
1644        assert_ne!(actual, blocked_port);
1645        assert!(actual > blocked_port);
1646        assert!(actual <= blocked_port + PORT_FALLBACK_RANGE);
1647    }
1648
1649    #[test]
1650    fn port_file_roundtrip() {
1651        write_port_file(7777, Some("com.example.app"), Some("Example"));
1652        let dir = discovery_dir();
1653        let content = std::fs::read_to_string(dir.join("port")).unwrap();
1654        assert_eq!(content, "7777");
1655        // Metadata file written
1656        let meta: serde_json::Value =
1657            serde_json::from_str(&std::fs::read_to_string(dir.join("metadata.json")).unwrap())
1658                .unwrap();
1659        assert_eq!(meta["port"], 7777);
1660        assert_eq!(meta["pid"], std::process::id());
1661        // App identity must be recorded so a discovery client can select the RIGHT app.
1662        assert_eq!(meta["identifier"], "com.example.app");
1663        assert_eq!(meta["product_name"], "Example");
1664        remove_port_file();
1665        assert!(!dir.exists());
1666    }
1667
1668    #[cfg(windows)]
1669    #[test]
1670    fn private_dir_restricts_shared_root_and_pid_dir() {
1671        let base = std::env::temp_dir()
1672            .join("victauri_private_root_test")
1673            .join(format!("p{}", std::process::id()));
1674        let dir = base.join("victauri").join("12345");
1675        let _ = std::fs::remove_dir_all(&base);
1676        std::fs::create_dir_all(&base).expect("create parent test dir");
1677
1678        assert!(
1679            ensure_private_dir(&dir),
1680            "a fresh discovery root and pid dir owned by this user should be accepted"
1681        );
1682        assert!(
1683            dir_owned_by_current_user(&base.join("victauri")),
1684            "shared discovery root must be owned by this process user"
1685        );
1686        assert!(
1687            dir_owned_by_current_user(&dir),
1688            "pid discovery dir must be owned by this process user"
1689        );
1690
1691        let _ = std::fs::remove_dir_all(&base);
1692    }
1693
1694    /// Audit N6: the discovery root was the shared `/tmp/victauri`; another local user who
1695    /// created it first blocked discovery for everyone else. It is per-user now.
1696    #[cfg(unix)]
1697    #[test]
1698    fn discovery_root_is_per_user() {
1699        use std::os::unix::fs::PermissionsExt;
1700        let euid = current_euid().expect("euid");
1701        let shared = std::env::temp_dir().join("victauri");
1702        let fallback = std::env::temp_dir().join(format!("victauri-{euid}"));
1703        assert_ne!(discovery_root(), shared);
1704        assert_eq!(discovery_root_from(None, Some(euid)), fallback);
1705
1706        // A private XDG runtime dir is preferred …
1707        let runtime = tempfile::tempdir().unwrap();
1708        std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
1709        assert_eq!(
1710            discovery_root_from(Some(runtime.path().into()), Some(euid)),
1711            runtime.path().join("victauri")
1712        );
1713        // … but not one others can reach, one we do not own, or a relative path.
1714        std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
1715        assert_eq!(
1716            discovery_root_from(Some(runtime.path().into()), Some(euid)),
1717            fallback
1718        );
1719        std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
1720        assert_eq!(
1721            discovery_root_from(Some(runtime.path().into()), Some(euid.wrapping_add(1))),
1722            std::env::temp_dir().join(format!("victauri-{}", euid.wrapping_add(1)))
1723        );
1724        assert_eq!(
1725            discovery_root_from(Some("relative/run".into()), Some(euid)),
1726            fallback
1727        );
1728    }
1729
1730    /// R5B-LINDISC1: another local user can pre-create the predictable `/tmp/victauri-<euid>`
1731    /// (and sticky `/tmp` stops us deleting it); the app then never registered. An untrusted
1732    /// primary root now falls back to the per-user home state root. (A root owned by another
1733    /// account needs a second account to set up — see the WSL run; a symlink or a non-directory
1734    /// at the primary path is refused by the same check.)
1735    #[cfg(unix)]
1736    #[test]
1737    fn an_untrusted_primary_root_falls_back_to_the_home_state_root() {
1738        use std::os::unix::fs::PermissionsExt;
1739        let base = tempfile::tempdir().unwrap();
1740        let home = base.path().join("home");
1741        std::fs::create_dir(&home).unwrap();
1742        std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700)).unwrap();
1743        let fallback = home_state_root(None, Some(home.clone().into()));
1744        let expected = home.join(".local").join("state").join("victauri");
1745        assert_eq!(fallback.as_deref(), Some(expected.as_path()));
1746
1747        // Taken over: a symlink planted at the primary path.
1748        let elsewhere = base.path().join("elsewhere");
1749        std::fs::create_dir(&elsewhere).unwrap();
1750        let hijacked = base.path().join("victauri-hijacked");
1751        std::os::unix::fs::symlink(&elsewhere, &hijacked).unwrap();
1752        assert_eq!(select_discovery_root(&hijacked, fallback.clone()), expected);
1753        assert!(unix_private_dir_is_trusted(&expected));
1754        let mode = std::fs::metadata(&expected).unwrap().permissions().mode() & 0o777;
1755        assert_eq!(mode, 0o700);
1756        // Blocked by a plain file.
1757        let blocked = base.path().join("victauri-blocked");
1758        std::fs::write(&blocked, "x").unwrap();
1759        assert_eq!(select_discovery_root(&blocked, fallback.clone()), expected);
1760
1761        // A usable primary is kept.
1762        let fine = base.path().join("victauri-fine");
1763        assert_eq!(select_discovery_root(&fine, fallback), fine);
1764
1765        // No usable fallback: the primary is returned and its check then refuses it.
1766        assert_eq!(select_discovery_root(&hijacked, None), hijacked);
1767    }
1768
1769    #[cfg(unix)]
1770    #[test]
1771    fn home_state_root_prefers_an_absolute_xdg_state_home_and_guards_its_parent() {
1772        use std::os::unix::fs::PermissionsExt;
1773        assert_eq!(
1774            home_state_root(Some("/x/state".into()), Some("/home/u".into())),
1775            Some(std::path::PathBuf::from("/x/state/victauri"))
1776        );
1777        assert_eq!(
1778            home_state_root(Some("rel/state".into()), Some("/home/u".into())),
1779            Some(std::path::PathBuf::from("/home/u/.local/state/victauri"))
1780        );
1781        assert_eq!(home_state_root(None, Some("rel".into())), None);
1782        assert_eq!(home_state_root(None, None), None);
1783
1784        // A parent other users can write is refused (they could swap the root out).
1785        let base = tempfile::tempdir().unwrap();
1786        let open = base.path().join("open");
1787        std::fs::create_dir(&open).unwrap();
1788        std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
1789        assert!(!ensure_home_state_root(&open.join("victauri")));
1790    }
1791
1792    #[cfg(unix)]
1793    #[test]
1794    fn private_dir_refuses_symlink_without_chmodding_target() {
1795        use std::os::unix::fs::PermissionsExt;
1796
1797        let base = tempfile::tempdir().unwrap();
1798        let target = base.path().join("target");
1799        let link = base.path().join("link");
1800        std::fs::create_dir(&target).unwrap();
1801        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1802        std::os::unix::fs::symlink(&target, &link).unwrap();
1803
1804        assert!(!ensure_unix_private_dir(&link));
1805        let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1806        assert_eq!(mode, 0o755, "symlink target permissions must be untouched");
1807    }
1808
1809    // ── parse_bridge_event: dom_interaction ────────────────────────────────
1810
1811    #[test]
1812    fn parse_dom_interaction_click() {
1813        let ev = serde_json::json!({
1814            "type": "dom_interaction",
1815            "action": "click",
1816            "selector": "#submit-btn",
1817        });
1818        let result = parse_bridge_event(&ev).expect("should produce an event");
1819        match result {
1820            AppEvent::DomInteraction {
1821                action,
1822                selector,
1823                value,
1824                webview_label,
1825                ..
1826            } => {
1827                assert_eq!(action, InteractionKind::Click);
1828                assert_eq!(selector, "#submit-btn");
1829                assert!(value.is_none());
1830                assert_eq!(webview_label, "main");
1831            }
1832            other => panic!("expected DomInteraction, got {other:?}"),
1833        }
1834    }
1835
1836    #[test]
1837    fn parse_dom_interaction_fill_with_value() {
1838        let ev = serde_json::json!({
1839            "type": "dom_interaction",
1840            "action": "fill",
1841            "selector": "input[name=email]",
1842            "value": "test@example.com",
1843        });
1844        let result = parse_bridge_event(&ev).expect("should produce an event");
1845        match result {
1846            AppEvent::DomInteraction {
1847                action,
1848                selector,
1849                value,
1850                ..
1851            } => {
1852                assert_eq!(action, InteractionKind::Fill);
1853                assert_eq!(selector, "input[name=email]");
1854                assert_eq!(value.as_deref(), Some("test@example.com"));
1855            }
1856            other => panic!("expected DomInteraction, got {other:?}"),
1857        }
1858    }
1859
1860    #[test]
1861    fn parse_dom_interaction_key_press() {
1862        let ev = serde_json::json!({
1863            "type": "dom_interaction",
1864            "action": "key_press",
1865            "selector": "body",
1866            "value": "Enter",
1867        });
1868        let result = parse_bridge_event(&ev).expect("should produce an event");
1869        match result {
1870            AppEvent::DomInteraction { action, value, .. } => {
1871                assert_eq!(action, InteractionKind::KeyPress);
1872                assert_eq!(value.as_deref(), Some("Enter"));
1873            }
1874            other => panic!("expected DomInteraction, got {other:?}"),
1875        }
1876    }
1877
1878    #[test]
1879    fn parse_dom_interaction_unknown_action_defaults_to_click() {
1880        let ev = serde_json::json!({
1881            "type": "dom_interaction",
1882            "action": "swipe_left",
1883            "selector": ".card",
1884        });
1885        let result = parse_bridge_event(&ev).expect("should produce an event");
1886        match result {
1887            AppEvent::DomInteraction { action, .. } => {
1888                assert_eq!(action, InteractionKind::Click);
1889            }
1890            other => panic!("expected DomInteraction, got {other:?}"),
1891        }
1892    }
1893
1894    #[test]
1895    fn parse_dom_interaction_missing_action_defaults_to_click() {
1896        let ev = serde_json::json!({
1897            "type": "dom_interaction",
1898            "selector": "button",
1899        });
1900        let result = parse_bridge_event(&ev).expect("should produce an event");
1901        match result {
1902            AppEvent::DomInteraction { action, .. } => {
1903                assert_eq!(action, InteractionKind::Click);
1904            }
1905            other => panic!("expected DomInteraction, got {other:?}"),
1906        }
1907    }
1908
1909    #[test]
1910    fn parse_dom_interaction_missing_selector_defaults_to_body() {
1911        let ev = serde_json::json!({
1912            "type": "dom_interaction",
1913            "action": "scroll",
1914        });
1915        let result = parse_bridge_event(&ev).expect("should produce an event");
1916        match result {
1917            AppEvent::DomInteraction {
1918                action, selector, ..
1919            } => {
1920                assert_eq!(action, InteractionKind::Scroll);
1921                assert_eq!(selector, "body");
1922            }
1923            other => panic!("expected DomInteraction, got {other:?}"),
1924        }
1925    }
1926
1927    #[test]
1928    fn parse_dom_interaction_all_action_kinds() {
1929        let cases = [
1930            ("click", InteractionKind::Click),
1931            ("double_click", InteractionKind::DoubleClick),
1932            ("fill", InteractionKind::Fill),
1933            ("key_press", InteractionKind::KeyPress),
1934            ("select", InteractionKind::Select),
1935            ("navigate", InteractionKind::Navigate),
1936            ("scroll", InteractionKind::Scroll),
1937        ];
1938        for (action_str, expected_kind) in cases {
1939            let ev = serde_json::json!({
1940                "type": "dom_interaction",
1941                "action": action_str,
1942                "selector": "body",
1943            });
1944            let result = parse_bridge_event(&ev)
1945                .unwrap_or_else(|| panic!("should produce event for action {action_str}"));
1946            match result {
1947                AppEvent::DomInteraction { action, .. } => {
1948                    assert_eq!(action, expected_kind, "mismatch for action {action_str}");
1949                }
1950                other => panic!("expected DomInteraction for {action_str}, got {other:?}"),
1951            }
1952        }
1953    }
1954
1955    // ── parse_bridge_event: ipc ────────────────────────────────────────────
1956
1957    #[test]
1958    fn parse_ipc_status_ok() {
1959        let ev = serde_json::json!({
1960            "type": "ipc",
1961            "command": "greet",
1962            "status": "ok",
1963            "duration_ms": 42.0,
1964        });
1965        let result = parse_bridge_event(&ev).expect("should produce an event");
1966        match result {
1967            AppEvent::Ipc(call) => {
1968                assert_eq!(call.command, "greet");
1969                assert_eq!(call.result, IpcResult::Ok(serde_json::Value::Null));
1970                assert_eq!(call.duration_ms, Some(42));
1971                assert_eq!(call.webview_label, "main");
1972            }
1973            other => panic!("expected Ipc, got {other:?}"),
1974        }
1975    }
1976
1977    #[test]
1978    fn parse_ipc_status_error() {
1979        let ev = serde_json::json!({
1980            "type": "ipc",
1981            "command": "save_file",
1982            "status": "error",
1983        });
1984        let result = parse_bridge_event(&ev).expect("should produce an event");
1985        match result {
1986            AppEvent::Ipc(call) => {
1987                assert_eq!(call.command, "save_file");
1988                assert_eq!(call.result, IpcResult::Err("error".to_string()));
1989            }
1990            other => panic!("expected Ipc, got {other:?}"),
1991        }
1992    }
1993
1994    #[test]
1995    fn parse_ipc_status_pending() {
1996        let ev = serde_json::json!({
1997            "type": "ipc",
1998            "command": "long_task",
1999        });
2000        let result = parse_bridge_event(&ev).expect("should produce an event");
2001        match result {
2002            AppEvent::Ipc(call) => {
2003                assert_eq!(call.result, IpcResult::Pending);
2004                assert!(call.duration_ms.is_none());
2005            }
2006            other => panic!("expected Ipc, got {other:?}"),
2007        }
2008    }
2009
2010    // ── parse_bridge_event: console ────────────────────────────────────────
2011
2012    #[test]
2013    fn parse_console_event() {
2014        let ev = serde_json::json!({
2015            "type": "console",
2016            "level": "warn",
2017            "message": "deprecated API usage",
2018        });
2019        let result = parse_bridge_event(&ev).expect("should produce an event");
2020        match result {
2021            AppEvent::Console { level, message, .. } => {
2022                assert_eq!(level, "warn");
2023                assert_eq!(message, "deprecated API usage");
2024            }
2025            other => panic!("expected Console, got {other:?}"),
2026        }
2027    }
2028
2029    #[test]
2030    fn parse_console_default_level() {
2031        let ev = serde_json::json!({
2032            "type": "console",
2033            "message": "hello",
2034        });
2035        let result = parse_bridge_event(&ev).expect("should produce an event");
2036        match result {
2037            AppEvent::Console { level, message, .. } => {
2038                assert_eq!(level, "log");
2039                assert_eq!(message, "hello");
2040            }
2041            other => panic!("expected Console, got {other:?}"),
2042        }
2043    }
2044
2045    // ── parse_bridge_event: navigation ─────────────────────────────────────
2046
2047    #[test]
2048    fn parse_navigation_event() {
2049        let ev = serde_json::json!({
2050            "type": "navigation",
2051            "nav_type": "push",
2052        });
2053        let result = parse_bridge_event(&ev).expect("should produce an event");
2054        match result {
2055            AppEvent::WindowEvent { label, event, .. } => {
2056                assert_eq!(label, "main");
2057                assert_eq!(event, "navigation.push");
2058            }
2059            other => panic!("expected WindowEvent, got {other:?}"),
2060        }
2061    }
2062
2063    #[test]
2064    fn parse_navigation_default_nav_type() {
2065        let ev = serde_json::json!({ "type": "navigation" });
2066        let result = parse_bridge_event(&ev).expect("should produce an event");
2067        match result {
2068            AppEvent::WindowEvent { event, .. } => {
2069                assert_eq!(event, "navigation.unknown");
2070            }
2071            other => panic!("expected WindowEvent, got {other:?}"),
2072        }
2073    }
2074
2075    // ── parse_bridge_event: dom_mutation ───────────────────────────────────
2076
2077    #[test]
2078    fn parse_dom_mutation_event() {
2079        let ev = serde_json::json!({
2080            "type": "dom_mutation",
2081            "count": 15,
2082        });
2083        let result = parse_bridge_event(&ev).expect("should produce an event");
2084        match result {
2085            AppEvent::DomMutation {
2086                webview_label,
2087                mutation_count,
2088                ..
2089            } => {
2090                assert_eq!(webview_label, "main");
2091                assert_eq!(mutation_count, 15);
2092            }
2093            other => panic!("expected DomMutation, got {other:?}"),
2094        }
2095    }
2096
2097    // ── parse_bridge_event: network ────────────────────────────────────────
2098
2099    #[test]
2100    fn parse_network_event() {
2101        let ev = serde_json::json!({
2102            "type": "network",
2103            "method": "POST",
2104            "url": "https://api.example.com/data",
2105        });
2106        let result = parse_bridge_event(&ev).expect("should produce an event");
2107        match result {
2108            AppEvent::StateChange { key, caused_by, .. } => {
2109                assert_eq!(key, "network.POST");
2110                assert_eq!(caused_by.as_deref(), Some("https://api.example.com/data"));
2111            }
2112            other => panic!("expected StateChange, got {other:?}"),
2113        }
2114    }
2115
2116    // ── parse_bridge_event: unknown type ───────────────────────────────────
2117
2118    #[test]
2119    fn parse_unknown_type_returns_none() {
2120        let ev = serde_json::json!({
2121            "type": "custom_telemetry",
2122            "payload": 42,
2123        });
2124        assert!(parse_bridge_event(&ev).is_none());
2125    }
2126
2127    #[test]
2128    fn parse_missing_type_field_returns_none() {
2129        let ev = serde_json::json!({ "data": "no type here" });
2130        assert!(parse_bridge_event(&ev).is_none());
2131    }
2132
2133    #[test]
2134    fn parse_empty_object_returns_none() {
2135        let ev = serde_json::json!({});
2136        assert!(parse_bridge_event(&ev).is_none());
2137    }
2138}