Skip to main content

reddb_server/server/
ui_bridge.rs

1//! Local `red ui` bridge — the tracer-bullet spine of the red-ui
2//! integration (issue #1042, PRD #1041; ADR 0047 bridge, ADR 0049
3//! RedWire-over-WS transport, ADR 0036 WS edge).
4//!
5//! `red ui --server file://<path>` opens a graphical UI in the browser
6//! against a local `.rdb`. This module is the process that makes that
7//! work: a single loopback (`127.0.0.1`) HTTP server that
8//!
9//!   * serves the UI bundle (a `--ui-dir` directory, or the checked-in
10//!     minimal fixture page when none is given), and
11//!   * mounts `/redwire` — a RedWire-over-WebSocket endpoint over the
12//!     **embedded engine** opened from the file. The WS data channel is
13//!     bridged into the same async-transport ↔ sync-engine seam the
14//!     internet WS edge uses ([`super::ws_edge::run_ws_session`], ADR
15//!     0036). This serves RedWire over the embedded engine — it is *not*
16//!     a proxy of the HTTP surface.
17//!
18//! Security (ADR 0036, adapted for loopback):
19//!   * **Origin allowlist, default-deny.** WebSocket is not covered by
20//!     CORS, so the upgrade validates the `Origin` header against an
21//!     explicit, exact-match allowlist ([`loopback_ws_origin_allowed`]).
22//!     The list is seeded with the bridge's own served origins
23//!     (`http://127.0.0.1:<port>` and `http://localhost:<port>`), so the
24//!     served page can connect and a cross-site page cannot.
25//!   * **WSS-only is relaxed.** The internet edge requires TLS
26//!     ([`super::ws_edge::ws_upgrade_decision`]); the loopback bridge
27//!     accepts plain `ws://` because it is bound to `127.0.0.1` and never
28//!     leaves the host. This is the one rule that differs from the
29//!     internet edge, and it is deliberate.
30//!
31//! The bridge is session-scoped: [`UiBridge::shutdown`] tears the server
32//! down cleanly (graceful shutdown of the listener + the serve task), so
33//! closing the UI / interrupting the command leaves no orphaned process.
34
35use std::net::SocketAddr;
36use std::path::PathBuf;
37use std::sync::Arc;
38
39use axum::extract::ws::{WebSocket, WebSocketUpgrade};
40use axum::extract::State;
41use axum::http::{header, HeaderMap, StatusCode, Uri};
42use axum::response::{IntoResponse, Response};
43use axum::routing::get;
44use tokio::sync::oneshot;
45
46use super::ws_edge::{
47    inject_bearer_handshake, pump_ws_stream, run_injected_ws_session, run_ws_session,
48    REDWIRE_WS_PATH, REDWIRE_WS_SUBPROTOCOL,
49};
50use super::RedDBServer;
51
52/// The checked-in minimal UI fixture served when no `--ui-dir` is given.
53/// A real bundle is downloaded at runtime in a later slice (PRD #1041);
54/// for now this page is enough to open a RedWire-over-WS session and run
55/// a query against the embedded engine.
56const FIXTURE_INDEX: &str = include_str!("ui_bridge_fixture/index.html");
57
58/// Configuration for [`spawn_ui_bridge`].
59#[derive(Debug, Clone, Default)]
60pub struct UiBridgeConfig {
61    /// Directory to serve the UI bundle from. `None` serves the embedded
62    /// [`FIXTURE_INDEX`] at `/` (and `/index.html`).
63    pub ui_dir: Option<PathBuf>,
64    /// Loopback port to bind. `0` (the default) picks an ephemeral port —
65    /// the resolved address is read back from [`UiBridge::local_addr`].
66    pub port: u16,
67    /// Bearer token held by the bridge and injected into the RedWire
68    /// handshake. The served page never receives the token.
69    pub injected_token: Option<String>,
70    /// Credential-free auth mode hint injected into served HTML.
71    pub auth_mode: super::ui_auth::UiAuthMode,
72}
73
74/// A remote RedWire endpoint the bridge fronts for a `red://` / `reds://`
75/// target (issue #1044, ADR 0047 bridge, ADR 0049 transport). The local
76/// loopback WS endpoint pumps its data channel straight into a fresh
77/// TCP (or TLS) connection to this host — the UI is unaware that the
78/// engine lives in another process / container.
79#[derive(Debug, Clone)]
80pub struct RemoteRedwireTarget {
81    /// Host to dial (the `red://`/`reds://` authority).
82    pub host: String,
83    /// Port to dial (defaults to `DEFAULT_PORT_RED` via the URI parser).
84    pub port: u16,
85    /// Negotiate TLS to the target (`reds://`). The handshake is
86    /// transparent to the UI.
87    pub tls: bool,
88    /// Optional CA bundle (PEM) to trust for the TLS handshake, on top of
89    /// the webpki system roots. Needed for a self-signed / private-CA
90    /// `reds://` target (a dev container); `None` trusts system roots only.
91    pub ca_pem: Option<Vec<u8>>,
92}
93
94/// What a running bridge fronts: the embedded engine opened from a
95/// `file://` target, or a remote RedWire endpoint (`red://` / `reds://`).
96/// Both are reached through the *same* loopback WS endpoint and the same
97/// byte-pump seam — only the far end of the pump differs.
98enum BridgeBackend {
99    /// `file://` — RedWire runs over the embedded engine in-process.
100    /// Boxed because `RedDBServer` is by far the largest variant payload
101    /// (≥280 bytes vs ≤56 for `Remote`); keeping it inline trips
102    /// `clippy::large_enum_variant` and bloats every `BridgeBackend`.
103    Embedded(Box<RedDBServer>),
104    /// `red://` / `reds://` — RedWire bytes are relayed to a remote
105    /// RedWire-over-TCP/TLS instance.
106    Remote(RemoteRedwireTarget),
107    /// `red+wss://` / `red+ws://` — no loopback relay; the browser
108    /// connects directly to the target. The `/redwire` route is not
109    /// mounted for this backend.
110    Direct,
111}
112
113/// How `red ui` should connect the browser to a target URI.
114///
115/// - [`UiTarget::File`] and [`UiTarget::Remote`] are *bridge-required*:
116///   a loopback WS relay is started and the UI only talks to that.
117/// - [`UiTarget::Direct`] is *bridge-free* (ADR 0047 direct-when-reachable):
118///   the browser connects to `ws_url` directly — only a static HTTP server
119///   to serve the UI bundle is started, with no WS relay process.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum UiTarget {
122    /// A local file / embedded-engine target. The caller canonicalises
123    /// the `file://` path and opens the engine itself.
124    File,
125    /// A remote RedWire-over-TCP (`red://`) or -TLS (`reds://`) target.
126    Remote(RemoteRedwireTargetSpec),
127    /// A browser-reachable WS endpoint (`red+wss://` or `red+ws://`).
128    /// The browser connects to `ws_url` directly; no loopback relay is
129    /// started. The UI bundle is still served from a local HTTP server.
130    Direct { ws_url: String },
131}
132
133/// Host / port / TLS triple parsed from a `red://` / `reds://` URI —
134/// the connection-independent part of [`RemoteRedwireTarget`] (no CA
135/// bytes), so it can derive `PartialEq` for classification tests.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct RemoteRedwireTargetSpec {
138    pub host: String,
139    pub port: u16,
140    pub tls: bool,
141}
142
143/// Classify a `red ui` target URI into the bridge backend it requires.
144///
145/// `file://` and bare filesystem paths resolve to [`UiTarget::File`];
146/// `red://host[:port]` and `reds://host[:port]` resolve to
147/// [`UiTarget::Remote`] with the parser's default port
148/// ([`reddb_wire::DEFAULT_PORT_RED`], 5050) when none is given and `tls`
149/// set for `reds://`. Any other scheme (or a cluster URI) is an error —
150/// `red ui` bridges exactly one endpoint.
151pub fn classify_ui_target(uri: &str) -> Result<UiTarget, String> {
152    // A bare path with no scheme is a file target (matches the existing
153    // `red ui ./data.rdb` shorthand).
154    if !uri.contains("://") {
155        return Ok(UiTarget::File);
156    }
157    match reddb_wire::parse(uri) {
158        Ok(reddb_wire::ConnectionTarget::File { .. }) => Ok(UiTarget::File),
159        Ok(reddb_wire::ConnectionTarget::RedWire { host, port, tls }) => {
160            Ok(UiTarget::Remote(RemoteRedwireTargetSpec {
161                host,
162                port,
163                tls,
164            }))
165        }
166        Ok(reddb_wire::ConnectionTarget::WsNative { host, port, tls }) => {
167            // ADR 0047: browser-reachable WS target — no loopback relay.
168            let scheme = if tls { "wss" } else { "ws" };
169            let ws_url = format!("{scheme}://{host}:{port}/redwire");
170            Ok(UiTarget::Direct { ws_url })
171        }
172        Ok(_) | Err(_) => Err(format!(
173            "unsupported target for red ui; supported schemes: \
174             file://, red://, reds://, red+ws://, red+wss://; got: {uri}"
175        )),
176    }
177}
178
179/// State threaded into the bridge's axum handlers. Cheap to clone (the
180/// backend is shared via `Arc`; the origin list and bundle dir likewise).
181#[derive(Clone)]
182struct BridgeState {
183    backend: Arc<BridgeBackend>,
184    allowed_origins: Arc<Vec<String>>,
185    ui_dir: Option<Arc<PathBuf>>,
186    injected_token: Option<Arc<String>>,
187    auth_mode: super::ui_auth::UiAuthMode,
188    /// Set for `Direct` targets. When `Some`, the WS URL is injected as
189    /// `window.REDDB_WS_URL` into HTML responses so the UI page can
190    /// connect directly rather than deriving from `location.host`.
191    direct_ws_url: Option<Arc<String>>,
192}
193
194/// A running loopback UI bridge. Holds the bound address plus the handles
195/// needed to shut the serve task down cleanly. Dropping it without
196/// calling [`Self::shutdown`] aborts the serve task on drop of the join
197/// handle's runtime; prefer `shutdown().await` for an orderly teardown.
198pub struct UiBridge {
199    local_addr: SocketAddr,
200    shutdown_tx: Option<oneshot::Sender<()>>,
201    join: tokio::task::JoinHandle<()>,
202    /// For `Direct` targets, the WS URL the browser connects to (the
203    /// remote endpoint). `None` for bridge targets — `ws_url()` derives
204    /// the loopback URL from `local_addr` in that case.
205    direct_ws_url: Option<String>,
206}
207
208impl UiBridge {
209    /// The loopback address the bridge is serving on.
210    pub fn local_addr(&self) -> SocketAddr {
211        self.local_addr
212    }
213
214    /// URL of the served UI bundle root — open this in a browser.
215    pub fn ui_url(&self) -> String {
216        format!("http://{}/", self.local_addr)
217    }
218
219    /// The WebSocket URL the served page connects to.
220    ///
221    /// For bridge targets (`file://`, `red://`, `reds://`) this is the
222    /// loopback endpoint on the same server. For direct targets
223    /// (`red+wss://`, `red+ws://`) this is the remote endpoint the browser
224    /// connects to without a relay.
225    pub fn ws_url(&self) -> String {
226        self.direct_ws_url
227            .clone()
228            .unwrap_or_else(|| format!("ws://{}{}", self.local_addr, REDWIRE_WS_PATH))
229    }
230
231    /// Signal graceful shutdown and wait for the serve task to wind down.
232    /// Session-scoped: the bridge process exits cleanly with no orphaned
233    /// listener once the UI is closed / the command is interrupted.
234    pub async fn shutdown(mut self) {
235        if let Some(tx) = self.shutdown_tx.take() {
236            let _ = tx.send(());
237        }
238        let _ = self.join.await;
239    }
240}
241
242/// Exact-match, default-deny Origin gate for the loopback `/redwire`
243/// upgrade (ADR 0036, adapted for loopback). An absent `Origin` is
244/// rejected (a browser always sends one); a present origin must appear
245/// verbatim in `allowed`. Kept pure so the policy is unit-tested without
246/// a live socket. The WSS-only rule of the internet edge does *not* apply
247/// here — the bridge is `127.0.0.1`-bound, so plain `ws://` is accepted.
248pub fn loopback_ws_origin_allowed(origin: Option<&str>, allowed: &[String]) -> bool {
249    match origin {
250        None => false,
251        Some(o) => allowed.iter().any(|a| a == o),
252    }
253}
254
255/// The served origins a page loaded from this bridge will present on its
256/// WebSocket upgrade — both the `127.0.0.1` literal and `localhost` form
257/// of the bound port. Seeded into the allowlist so the bundle can connect
258/// while a cross-site origin cannot.
259fn seed_loopback_origins(port: u16) -> Vec<String> {
260    vec![
261        format!("http://127.0.0.1:{port}"),
262        format!("http://localhost:{port}"),
263    ]
264}
265
266/// Bind a loopback HTTP server that serves the UI bundle and mounts the
267/// RedWire-over-WS endpoint over `server`'s embedded engine, then spawn
268/// its serve loop. Returns once the listener is bound; the returned
269/// [`UiBridge`] carries the resolved address and a clean-shutdown handle.
270///
271/// Must be called from within a tokio runtime (it binds a tokio listener
272/// and spawns the serve task).
273pub async fn spawn_ui_bridge(
274    server: RedDBServer,
275    config: UiBridgeConfig,
276) -> std::io::Result<UiBridge> {
277    spawn_ui_bridge_backend(BridgeBackend::Embedded(Box::new(server)), config).await
278}
279
280/// Like [`spawn_ui_bridge`] but fronting a *remote* RedWire endpoint
281/// (`red://` / `reds://`, issue #1044) rather than the embedded engine.
282/// The served UI still talks only to the loopback WS endpoint; each WS
283/// session opens a fresh TCP/TLS connection to `target`, and the byte
284/// stream is pumped through transparently.
285pub async fn spawn_ui_bridge_remote(
286    target: RemoteRedwireTarget,
287    config: UiBridgeConfig,
288) -> std::io::Result<UiBridge> {
289    spawn_ui_bridge_backend(BridgeBackend::Remote(target), config).await
290}
291
292async fn spawn_ui_bridge_backend(
293    backend: BridgeBackend,
294    config: UiBridgeConfig,
295) -> std::io::Result<UiBridge> {
296    let listener = tokio::net::TcpListener::bind(("127.0.0.1", config.port)).await?;
297    let local_addr = listener.local_addr()?;
298
299    let state = BridgeState {
300        backend: Arc::new(backend),
301        allowed_origins: Arc::new(seed_loopback_origins(local_addr.port())),
302        ui_dir: config.ui_dir.map(Arc::new),
303        injected_token: config.injected_token.map(Arc::new),
304        auth_mode: config.auth_mode,
305        direct_ws_url: None,
306    };
307
308    let router = axum::Router::new()
309        .route(REDWIRE_WS_PATH, get(loopback_redwire_upgrade))
310        .fallback(serve_ui)
311        .with_state(state);
312
313    let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
314    let join = tokio::spawn(async move {
315        let _ = axum::serve(listener, router)
316            .with_graceful_shutdown(async move {
317                let _ = shutdown_rx.await;
318            })
319            .await;
320    });
321
322    Ok(UiBridge {
323        local_addr,
324        shutdown_tx: Some(shutdown_tx),
325        join,
326        direct_ws_url: None,
327    })
328}
329
330/// Serve the UI bundle for a **direct** `red+wss://` / `red+ws://` target
331/// (ADR 0047). No loopback WS relay is started — the browser connects to
332/// `ws_url` directly. A `window.REDDB_WS_URL` config is injected into HTML
333/// responses so the UI page knows the target without user input.
334pub async fn spawn_direct_ui_server(
335    ws_url: String,
336    config: UiBridgeConfig,
337) -> std::io::Result<UiBridge> {
338    let listener = tokio::net::TcpListener::bind(("127.0.0.1", config.port)).await?;
339    let local_addr = listener.local_addr()?;
340
341    let state = BridgeState {
342        backend: Arc::new(BridgeBackend::Direct),
343        allowed_origins: Arc::new(vec![]),
344        ui_dir: config.ui_dir.map(Arc::new),
345        injected_token: config.injected_token.map(Arc::new),
346        auth_mode: config.auth_mode,
347        direct_ws_url: Some(Arc::new(ws_url.clone())),
348    };
349
350    // No /redwire route — the browser owns its own WS connection.
351    let router = axum::Router::new().fallback(serve_ui).with_state(state);
352
353    let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
354    let join = tokio::spawn(async move {
355        let _ = axum::serve(listener, router)
356            .with_graceful_shutdown(async move {
357                let _ = shutdown_rx.await;
358            })
359            .await;
360    });
361
362    Ok(UiBridge {
363        local_addr,
364        shutdown_tx: Some(shutdown_tx),
365        join,
366        direct_ws_url: Some(ws_url),
367    })
368}
369
370/// axum handler for `GET /redwire`. Enforces the loopback Origin gate,
371/// then upgrades to a binary WebSocket and runs a RedWire session over it
372/// against the embedded engine (the same seam as the internet WS edge).
373async fn loopback_redwire_upgrade(
374    State(state): State<BridgeState>,
375    headers: HeaderMap,
376    ws: WebSocketUpgrade,
377) -> Response {
378    let origin = headers
379        .get(header::ORIGIN)
380        .and_then(|value| value.to_str().ok());
381
382    if !loopback_ws_origin_allowed(origin, &state.allowed_origins) {
383        return (
384            StatusCode::FORBIDDEN,
385            "origin not allowed for loopback redwire websocket",
386        )
387            .into_response();
388    }
389
390    let backend = Arc::clone(&state.backend);
391    let injected_token = state.injected_token.clone();
392    ws.protocols([REDWIRE_WS_SUBPROTOCOL])
393        .on_upgrade(move |socket| async move {
394            match &*backend {
395                // `file://` — RedWire over the in-process embedded engine.
396                BridgeBackend::Embedded(server) => {
397                    if let Some(token) = injected_token.as_deref().map(String::as_str) {
398                        run_injected_ws_session(socket, (**server).clone(), token).await;
399                    } else {
400                        run_ws_session(socket, (**server).clone()).await;
401                    }
402                }
403                // `red://` / `reds://` — relay to a remote RedWire instance.
404                BridgeBackend::Remote(target) => {
405                    run_remote_ws_session(
406                        socket,
407                        target,
408                        injected_token.as_deref().map(String::as_str),
409                    )
410                    .await;
411                }
412                // `red+wss://` / `red+ws://` — the `/redwire` route is not
413                // mounted for direct targets, so this arm is unreachable.
414                BridgeBackend::Direct => {
415                    close_ws(socket).await;
416                }
417            }
418        })
419}
420
421/// Bridge the binary WebSocket to a remote RedWire-over-TCP/TLS endpoint.
422///
423/// Opens a fresh connection to `target` per WS session and pumps the byte
424/// stream straight through ([`pump_ws_stream`]). The browser sends the
425/// exact native preamble (`0xFE` magic + minor + `Hello`), so the remote
426/// listener's standalone session handles it unchanged — the bridge is a
427/// pure byte relay and never parses RedWire frames. On a connection
428/// failure the WS is closed so the UI surfaces the error rather than
429/// hanging.
430async fn run_remote_ws_session(
431    socket: WebSocket,
432    target: &RemoteRedwireTarget,
433    injected_token: Option<&str>,
434) {
435    let addr = (target.host.as_str(), target.port);
436    let tcp = match tokio::net::TcpStream::connect(addr).await {
437        Ok(tcp) => tcp,
438        Err(err) => {
439            tracing::warn!(
440                host = %target.host,
441                port = target.port,
442                err = %err,
443                "ui bridge: connect to remote redwire target failed"
444            );
445            close_ws(socket).await;
446            return;
447        }
448    };
449
450    if !target.tls {
451        if let Some(token) = injected_token {
452            inject_bearer_handshake(socket, tcp, token).await;
453        } else {
454            pump_ws_stream(socket, tcp).await;
455        }
456        return;
457    }
458
459    match wrap_remote_tls(tcp, target).await {
460        Ok(tls) => {
461            if let Some(token) = injected_token {
462                inject_bearer_handshake(socket, tls, token).await;
463            } else {
464                pump_ws_stream(socket, tls).await;
465            }
466        }
467        Err(err) => {
468            tracing::warn!(
469                host = %target.host,
470                port = target.port,
471                err = %err,
472                "ui bridge: TLS handshake to remote redwire target failed"
473            );
474            close_ws(socket).await;
475        }
476    }
477}
478
479/// Send a best-effort close frame on a WS the bridge is abandoning.
480async fn close_ws(mut socket: WebSocket) {
481    let _ = socket.send(axum::extract::ws::Message::Close(None)).await;
482}
483
484/// Negotiate client-side TLS to a `reds://` target. Trusts the webpki
485/// system roots, plus any caller-supplied CA bundle (PEM) — enough for a
486/// public-cert target out of the box and a self-signed / private-CA dev
487/// container when a `--tls-ca` bundle is passed. Server-only TLS (no
488/// client cert): RedWire auth is negotiated inside the handshake, exactly
489/// as on the native socket transports.
490async fn wrap_remote_tls(
491    tcp: tokio::net::TcpStream,
492    target: &RemoteRedwireTarget,
493) -> std::io::Result<tokio_rustls::client::TlsStream<tokio::net::TcpStream>> {
494    use rustls::pki_types::ServerName;
495    use rustls::{ClientConfig, RootCertStore};
496
497    let _ = rustls::crypto::ring::default_provider().install_default();
498
499    let mut roots = RootCertStore::empty();
500    // Seed the webpki system roots so a public-cert `reds://` works
501    // without an explicit CA.
502    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
503    // Add any caller-supplied CA (a self-signed dev container / private CA).
504    if let Some(pem) = &target.ca_pem {
505        let mut reader = std::io::BufReader::new(&pem[..]);
506        for cert in rustls_pemfile::certs(&mut reader) {
507            let cert = cert.map_err(std::io::Error::other)?;
508            roots
509                .add(cert)
510                .map_err(|e| std::io::Error::other(format!("add CA cert: {e}")))?;
511        }
512    }
513
514    let config = ClientConfig::builder()
515        .with_root_certificates(roots)
516        .with_no_client_auth();
517
518    let connector = tokio_rustls::TlsConnector::from(Arc::new(config));
519    let server_name = ServerName::try_from(target.host.clone())
520        .map_err(|e| std::io::Error::other(format!("invalid TLS server name: {e}")))?;
521    connector.connect(server_name, tcp).await
522}
523
524/// Static-file fallback: serve the UI bundle. With a `--ui-dir`, files are
525/// read from that directory (`/` → `index.html`), guarded against path
526/// traversal; without one, the embedded fixture answers `/` and
527/// `/index.html` and everything else is 404.
528///
529/// For direct targets (`BridgeBackend::Direct`), the WS URL config
530/// (`window.REDDB_WS_URL`) is injected before `</head>` in HTML responses
531/// so the UI page can connect to the remote endpoint directly.
532async fn serve_ui(State(state): State<BridgeState>, uri: Uri) -> Response {
533    let raw = uri.path();
534    let rel = raw.trim_start_matches('/');
535    let rel = if rel.is_empty() { "index.html" } else { rel };
536
537    let (content_type, mut body) = match &state.ui_dir {
538        None => {
539            if rel == "index.html" {
540                (
541                    "text/html; charset=utf-8",
542                    FIXTURE_INDEX.as_bytes().to_vec(),
543                )
544            } else {
545                return not_found();
546            }
547        }
548        Some(dir) => {
549            // Reject traversal: no `..` or `.` segments, no absolute
550            // re-rooting. Only plain, forward components are served.
551            if rel
552                .split('/')
553                .any(|seg| seg == ".." || seg == "." || seg.is_empty())
554            {
555                return not_found();
556            }
557            let full = dir.join(rel);
558            // Read off the async runtime — `tokio::fs` is not enabled, and
559            // these are small local bundle assets on loopback anyway.
560            match tokio::task::spawn_blocking(move || std::fs::read(&full)).await {
561                Ok(Ok(bytes)) => (content_type_for(rel), bytes),
562                _ => return not_found(),
563            }
564        }
565    };
566
567    // For direct targets, inject `window.REDDB_WS_URL` before </head> so
568    // the UI page knows the remote WS endpoint without a loopback relay.
569    if content_type.starts_with("text/html") {
570        if let Some(ws_url) = &state.direct_ws_url {
571            body = inject_ws_url_config(body, ws_url);
572        }
573        body = super::ui_auth::inject_auth_mode_config(body, state.auth_mode);
574    }
575
576    (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], body).into_response()
577}
578
579/// Inject `<script>window.REDDB_WS_URL="<ws_url>";</script>` just before
580/// `</head>` in an HTML document. The ws_url is constructed from a
581/// validated URI (scheme + host + port) and contains no `"` or `\`, so
582/// simple string interpolation is safe. Returns the original bytes
583/// unchanged when `</head>` is not found.
584fn inject_ws_url_config(html: Vec<u8>, ws_url: &str) -> Vec<u8> {
585    let snippet = format!("<script>window.REDDB_WS_URL=\"{ws_url}\";</script>");
586    let marker = b"</head>";
587    match html.windows(marker.len()).position(|w| w == marker) {
588        Some(pos) => {
589            let mut out = Vec::with_capacity(html.len() + snippet.len());
590            out.extend_from_slice(&html[..pos]);
591            out.extend_from_slice(snippet.as_bytes());
592            out.extend_from_slice(&html[pos..]);
593            out
594        }
595        None => html,
596    }
597}
598
599/// Guess a content type from a file extension. Minimal map covering the
600/// asset kinds a UI bundle ships; anything unknown is served as opaque
601/// bytes. Shared with the server-side static surface ([`super::ui_static`],
602/// `red server --ui`) so the two bundle-serving paths agree on MIME types.
603pub(crate) fn content_type_for(path: &str) -> &'static str {
604    let ext = path.rsplit('.').next().unwrap_or("");
605    match ext {
606        "html" | "htm" => "text/html; charset=utf-8",
607        "js" | "mjs" => "text/javascript; charset=utf-8",
608        "css" => "text/css; charset=utf-8",
609        "json" => "application/json; charset=utf-8",
610        "svg" => "image/svg+xml",
611        "png" => "image/png",
612        "jpg" | "jpeg" => "image/jpeg",
613        "gif" => "image/gif",
614        "ico" => "image/x-icon",
615        "wasm" => "application/wasm",
616        "map" => "application/json; charset=utf-8",
617        "txt" => "text/plain; charset=utf-8",
618        _ => "application/octet-stream",
619    }
620}
621
622fn content_type_response(path: &str, body: Vec<u8>) -> Response {
623    (
624        StatusCode::OK,
625        [(header::CONTENT_TYPE, content_type_for(path))],
626        body,
627    )
628        .into_response()
629}
630
631fn html_response(body: Vec<u8>) -> Response {
632    (
633        StatusCode::OK,
634        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
635        body,
636    )
637        .into_response()
638}
639
640fn not_found() -> Response {
641    (StatusCode::NOT_FOUND, "not found").into_response()
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    fn origins() -> Vec<String> {
649        seed_loopback_origins(7777)
650    }
651
652    #[test]
653    fn served_origin_is_allowed() {
654        assert!(loopback_ws_origin_allowed(
655            Some("http://127.0.0.1:7777"),
656            &origins()
657        ));
658        assert!(loopback_ws_origin_allowed(
659            Some("http://localhost:7777"),
660            &origins()
661        ));
662    }
663
664    #[test]
665    fn missing_origin_is_rejected() {
666        assert!(!loopback_ws_origin_allowed(None, &origins()));
667    }
668
669    #[test]
670    fn cross_site_origin_is_rejected() {
671        assert!(!loopback_ws_origin_allowed(
672            Some("http://evil.example.com"),
673            &origins()
674        ));
675        // A different port is a different origin — exact match only.
676        assert!(!loopback_ws_origin_allowed(
677            Some("http://127.0.0.1:9999"),
678            &origins()
679        ));
680    }
681
682    #[test]
683    fn empty_allowlist_denies_every_origin() {
684        assert!(!loopback_ws_origin_allowed(
685            Some("http://127.0.0.1:7777"),
686            &[]
687        ));
688    }
689
690    // ----------------------------------------------------------------
691    // Scheme classification (issue #1044). `red://` / `reds://` are
692    // bridge-required remote targets and resolve to the parser's default
693    // port; `file://` and bare paths stay local.
694    // ----------------------------------------------------------------
695
696    #[test]
697    fn red_scheme_classifies_as_remote_plaintext_default_port() {
698        // No port → the shared parser's DEFAULT_PORT_RED (5050), no TLS.
699        assert_eq!(
700            classify_ui_target("red://db.internal").unwrap(),
701            UiTarget::Remote(RemoteRedwireTargetSpec {
702                host: "db.internal".to_string(),
703                port: reddb_wire::DEFAULT_PORT_RED,
704                tls: false,
705            })
706        );
707        assert_eq!(reddb_wire::DEFAULT_PORT_RED, 5050);
708    }
709
710    #[test]
711    fn reds_scheme_classifies_as_remote_tls_default_port() {
712        assert_eq!(
713            classify_ui_target("reds://db.internal").unwrap(),
714            UiTarget::Remote(RemoteRedwireTargetSpec {
715                host: "db.internal".to_string(),
716                port: reddb_wire::DEFAULT_PORT_RED,
717                tls: true,
718            })
719        );
720    }
721
722    #[test]
723    fn red_scheme_honours_explicit_port() {
724        assert_eq!(
725            classify_ui_target("red://127.0.0.1:6000").unwrap(),
726            UiTarget::Remote(RemoteRedwireTargetSpec {
727                host: "127.0.0.1".to_string(),
728                port: 6000,
729                tls: false,
730            })
731        );
732        assert_eq!(
733            classify_ui_target("reds://host:7001").unwrap(),
734            UiTarget::Remote(RemoteRedwireTargetSpec {
735                host: "host".to_string(),
736                port: 7001,
737                tls: true,
738            })
739        );
740    }
741
742    #[test]
743    fn file_and_bare_path_classify_as_local() {
744        assert_eq!(
745            classify_ui_target("file:///var/lib/db.rdb").unwrap(),
746            UiTarget::File
747        );
748        assert_eq!(classify_ui_target("./data.rdb").unwrap(), UiTarget::File);
749        assert_eq!(classify_ui_target("data.rdb").unwrap(), UiTarget::File);
750    }
751
752    #[test]
753    fn unsupported_scheme_is_rejected() {
754        // gRPC / http / a cluster URI are not single RedWire endpoints.
755        assert!(classify_ui_target("grpc://host:5055").is_err());
756        assert!(classify_ui_target("http://host").is_err());
757        assert!(classify_ui_target("red://a,b").is_err());
758    }
759
760    // ----------------------------------------------------------------
761    // Direct targets (issue #1045, ADR 0047 direct-when-reachable).
762    // `red+wss://` and `red+ws://` are browser-reachable WS endpoints —
763    // no loopback relay is needed.
764    // ----------------------------------------------------------------
765
766    #[test]
767    fn red_plus_wss_classifies_as_direct_default_port() {
768        assert_eq!(
769            classify_ui_target("red+wss://mydb.db.reddb.io").unwrap(),
770            UiTarget::Direct {
771                ws_url: "wss://mydb.db.reddb.io:443/redwire".to_string(),
772            }
773        );
774    }
775
776    #[test]
777    fn red_plus_wss_with_explicit_port_classifies_as_direct() {
778        assert_eq!(
779            classify_ui_target("red+wss://host:5055").unwrap(),
780            UiTarget::Direct {
781                ws_url: "wss://host:5055/redwire".to_string(),
782            }
783        );
784    }
785
786    #[test]
787    fn red_plus_ws_classifies_as_direct_plaintext() {
788        assert_eq!(
789            classify_ui_target("red+ws://host:8080").unwrap(),
790            UiTarget::Direct {
791                ws_url: "ws://host:8080/redwire".to_string(),
792            }
793        );
794    }
795
796    #[test]
797    fn unsupported_scheme_error_names_supported_set() {
798        let err = classify_ui_target("mongodb://host").unwrap_err();
799        for scheme in ["file://", "red://", "reds://", "red+ws://", "red+wss://"] {
800            assert!(
801                err.contains(scheme),
802                "error must mention {scheme}: got: {err}"
803            );
804        }
805    }
806
807    // ----------------------------------------------------------------
808    // inject_ws_url_config — config injection into HTML.
809    // ----------------------------------------------------------------
810
811    #[test]
812    fn inject_ws_url_inserts_before_head_close() {
813        let html = b"<html><head></head><body></body></html>".to_vec();
814        let out = inject_ws_url_config(html, "wss://host:443/redwire");
815        let s = String::from_utf8(out).unwrap();
816        assert!(
817            s.contains("<script>window.REDDB_WS_URL=\"wss://host:443/redwire\";</script></head>"),
818            "snippet must appear before </head>: {s}"
819        );
820    }
821
822    #[test]
823    fn inject_ws_url_noop_when_no_head_close() {
824        let html = b"<html><body>no head close</body></html>".to_vec();
825        let orig = html.clone();
826        let out = inject_ws_url_config(html, "wss://host/redwire");
827        assert_eq!(out, orig, "html without </head> must be returned unchanged");
828    }
829
830    #[test]
831    fn content_types_cover_bundle_assets() {
832        assert_eq!(content_type_for("index.html"), "text/html; charset=utf-8");
833        assert_eq!(content_type_for("app.js"), "text/javascript; charset=utf-8");
834        assert_eq!(content_type_for("style.css"), "text/css; charset=utf-8");
835        assert_eq!(content_type_for("data.bin"), "application/octet-stream");
836    }
837}