Skip to main content

majordomus_cli/http/
router.rs

1//! Routing and binding, transport-neutral: a request in, a response out.
2//!
3//! The router names no surface. It asks [`super::surfaces::Served`] — the web topology this
4//! process resolved, narrowed to what it can answer — who owns a path, and dispatches to
5//! what that surface bound. A route under `/api/v1/` is a capability with an HTTP exposure;
6//! `/`, `/openapi.json`, `/swagger`, `/cockpit` and `/mcp` are routes the executable answers
7//! itself; `/docs/` and every generated report are directories a producer wrote. All of them
8//! are declared once, in `crate::web::discover`, and reach the router, the home page, the
9//! `web.surfaces` capability and the publication from there.
10
11use std::sync::Arc;
12
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use serde_json::{json, Value};
16
17use crate::capability::{CapabilityError, CaseContext, Context, HttpMethod};
18use crate::cockpit::Cockpit;
19use crate::live::{IntoLive, Live, Memo};
20use crate::ui::Ui;
21use crate::web::discover::Runtime;
22use crate::web::home;
23
24use super::events;
25use super::mcp::McpEndpoint;
26use super::surfaces::{Bound, Native, Served};
27use super::{openapi, swagger};
28
29/// A request as the router sees it: method, path without query, decoded query pairs,
30/// headers, and the body.
31#[derive(Debug, Clone)]
32pub struct Request {
33    /// `GET`, `POST`, ... as received (a `HEAD` arrives as `GET`).
34    pub method: String,
35    /// The path without the query string.
36    pub path: String,
37    /// The query pairs, percent-decoded, in order.
38    pub query: Vec<(String, String)>,
39    /// Header names and values as received; looked up case-insensitively.
40    pub headers: Vec<(String, String)>,
41    /// The body, raw.
42    pub body: Vec<u8>,
43    /// The address the request came from, when it came over a socket. A request built in
44    /// this process (a test, the Cockpit composing a call) has none, and is this machine's.
45    pub remote: Option<std::net::IpAddr>,
46}
47
48impl Request {
49    /// Split a request target (`/api/v1/objects?kind=rule`) into path and decoded pairs.
50    ///
51    /// ```
52    /// use majordomus_cli::http::Request;
53    /// let r = Request::parse_target("GET", "/api/v1/search?query=Git%20is&limit=2", vec![]);
54    /// assert_eq!(r.path, "/api/v1/search");
55    /// assert_eq!(r.query, vec![("query".to_string(), "Git is".to_string()), ("limit".to_string(), "2".to_string())]);
56    /// ```
57    pub fn parse_target(method: &str, target: &str, body: Vec<u8>) -> Self {
58        let (path, query) = match target.split_once('?') {
59            Some((p, q)) => (p.to_string(), q),
60            None => (target.to_string(), ""),
61        };
62        let query = query
63            .split('&')
64            .filter(|s| !s.is_empty())
65            .map(|pair| {
66                let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
67                (percent_decode(k), percent_decode(v))
68            })
69            .collect();
70        Request {
71            method: method.to_string(),
72            path,
73            query,
74            headers: Vec::new(),
75            body,
76            remote: None,
77        }
78    }
79
80    /// The request that carries `input` to a capability's route the way the binding says:
81    /// `GET` puts every top-level property that is not `null` on the query string (a
82    /// scalar as its text, anything else as JSON), `POST` sends the input as the body.
83    /// The inverse of the router's binding, so a benchmark case reaches the route as a
84    /// client would send it.
85    ///
86    /// ```
87    /// use majordomus_cli::capability::HttpMethod;
88    /// use majordomus_cli::http::Request;
89    /// use serde_json::json;
90    /// let r = Request::bind(HttpMethod::Get, "/api/v1/search", &json!({ "query": "a b", "limit": 2, "kind": null }));
91    /// assert_eq!(r.path, "/api/v1/search");
92    /// assert_eq!(r.query, vec![("query".to_string(), "a b".to_string()), ("limit".to_string(), "2".to_string())]);
93    /// let p = Request::bind(HttpMethod::Post, "/api/v1/peers/announce", &json!({ "intent": "x" }));
94    /// assert_eq!(p.body, br#"{"intent":"x"}"#);
95    /// ```
96    pub fn bind(method: HttpMethod, path: &str, input: &Value) -> Self {
97        match method {
98            HttpMethod::Get => Request {
99                method: "GET".into(),
100                path: path.into(),
101                query: input
102                    .as_object()
103                    .map(|m| {
104                        m.iter()
105                            .filter(|(_, v)| !v.is_null())
106                            .map(|(k, v)| {
107                                let text = match v {
108                                    Value::String(s) => s.clone(),
109                                    other => other.to_string(),
110                                };
111                                (k.clone(), text)
112                            })
113                            .collect()
114                    })
115                    .unwrap_or_default(),
116                headers: Vec::new(),
117                body: Vec::new(),
118                remote: None,
119            },
120            HttpMethod::Post => Request {
121                method: "POST".into(),
122                path: path.into(),
123                query: Vec::new(),
124                headers: Vec::new(),
125                body: input.to_string().into_bytes(),
126                remote: None,
127            },
128        }
129    }
130
131    /// The request target this request would be sent as: the path with the query string,
132    /// percent-encoded.
133    ///
134    /// ```
135    /// use majordomus_cli::capability::HttpMethod;
136    /// use majordomus_cli::http::Request;
137    /// let r = Request::bind(HttpMethod::Get, "/api/v1/search", &serde_json::json!({ "query": "a b&c" }));
138    /// assert_eq!(r.target(), "/api/v1/search?query=a%20b%26c");
139    /// ```
140    pub fn target(&self) -> String {
141        if self.query.is_empty() {
142            return self.path.clone();
143        }
144        let pairs: Vec<String> = self
145            .query
146            .iter()
147            .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
148            .collect();
149        format!("{}?{}", self.path, pairs.join("&"))
150    }
151
152    /// The same request with its headers.
153    pub fn with_headers(mut self, headers: Vec<(String, String)>) -> Self {
154        self.headers = headers;
155        self
156    }
157
158    /// The same request, as having come from `address`.
159    ///
160    /// ```
161    /// use majordomus_cli::http::Request;
162    /// let r = Request::parse_target("GET", "/", vec![]).with_remote(Some("10.0.0.2".parse().unwrap()));
163    /// assert_eq!(r.remote, Some("10.0.0.2".parse().unwrap()));
164    /// ```
165    pub fn with_remote(mut self, address: Option<std::net::IpAddr>) -> Self {
166        self.remote = address;
167        self
168    }
169
170    /// Did this request come from another host?
171    ///
172    /// Loopback is this machine, in either family and in the IPv4-mapped IPv6 form a
173    /// dual-stack socket reports. A request with no address was built in this process.
174    ///
175    /// ```
176    /// use majordomus_cli::http::Request;
177    /// let r = |a: &str| Request::parse_target("POST", "/", vec![]).with_remote(Some(a.parse().unwrap()));
178    /// assert!(!r("127.0.0.1").is_remote());
179    /// assert!(!r("::1").is_remote());
180    /// assert!(!r("::ffff:127.0.0.1").is_remote());
181    /// assert!(r("192.168.100.30").is_remote());
182    /// assert!(r("100.92.246.32").is_remote());
183    /// assert!(r("::ffff:10.0.0.2").is_remote());
184    /// assert!(!Request::parse_target("POST", "/", vec![]).is_remote());
185    /// ```
186    pub fn is_remote(&self) -> bool {
187        self.remote
188            .is_some_and(|ip| !ip.to_canonical().is_loopback())
189    }
190
191    /// A header value, by case-insensitive name.
192    ///
193    /// ```
194    /// use majordomus_cli::http::Request;
195    /// let r = Request::parse_target("POST", "/mcp", vec![])
196    ///     .with_headers(vec![("Mcp-Session-Id".into(), "abc".into())]);
197    /// assert_eq!(r.header("mcp-session-id"), Some("abc"));
198    /// assert_eq!(r.header("x-none"), None);
199    /// ```
200    pub fn header(&self, name: &str) -> Option<&str> {
201        self.headers
202            .iter()
203            .find(|(k, _)| k.eq_ignore_ascii_case(name))
204            .map(|(_, v)| v.as_str())
205    }
206}
207
208/// What a response carries. Text for everything this projection computes; bytes for a file
209/// it serves from disk, which is not always UTF-8 and must not be repaired into it.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub enum Body {
212    /// A string this process produced.
213    Text(String),
214    /// Bytes read from somewhere else, passed through unchanged.
215    Bytes(Vec<u8>),
216}
217
218impl Body {
219    /// The bytes on the wire.
220    pub fn as_bytes(&self) -> &[u8] {
221        match self {
222            Body::Text(t) => t.as_bytes(),
223            Body::Bytes(b) => b,
224        }
225    }
226
227    /// How many bytes it is.
228    pub fn len(&self) -> usize {
229        self.as_bytes().len()
230    }
231
232    /// Is it empty?
233    pub fn is_empty(&self) -> bool {
234        self.len() == 0
235    }
236
237    /// The body as text: borrowed when it is text, and lossy when it is not, which only a
238    /// diagnostic or a test ever asks for.
239    ///
240    /// ```
241    /// use majordomus_cli::http::router::Body;
242    /// assert_eq!(Body::Text("a".into()).text(), "a");
243    /// assert_eq!(Body::Bytes(vec![0xff]).text(), "\u{fffd}");
244    /// ```
245    pub fn text(&self) -> std::borrow::Cow<'_, str> {
246        match self {
247            Body::Text(t) => std::borrow::Cow::Borrowed(t),
248            Body::Bytes(b) => String::from_utf8_lossy(b),
249        }
250    }
251}
252
253impl From<String> for Body {
254    fn from(value: String) -> Self {
255        Body::Text(value)
256    }
257}
258
259impl From<&str> for Body {
260    fn from(value: &str) -> Self {
261        Body::Text(value.to_string())
262    }
263}
264
265impl From<Vec<u8>> for Body {
266    fn from(value: Vec<u8>) -> Self {
267        Body::Bytes(value)
268    }
269}
270
271impl PartialEq<str> for Body {
272    fn eq(&self, other: &str) -> bool {
273        self.as_bytes() == other.as_bytes()
274    }
275}
276
277impl PartialEq<&str> for Body {
278    fn eq(&self, other: &&str) -> bool {
279        self.as_bytes() == other.as_bytes()
280    }
281}
282
283impl std::fmt::Display for Body {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        f.write_str(&self.text())
286    }
287}
288
289#[derive(Debug, Clone, PartialEq, Eq)]
290/// A response as the router produces it; the server adds the wire.
291pub struct Response {
292    /// The HTTP status.
293    pub status: u16,
294    /// The `Content-Type` value.
295    pub content_type: &'static str,
296    /// The body.
297    pub body: Body,
298    /// Further headers (`Mcp-Session-Id`); the server adds `Content-Length` and `Cache-Control`.
299    pub headers: Vec<(String, String)>,
300}
301
302impl Response {
303    /// A response with no further headers.
304    pub fn new(status: u16, content_type: &'static str, body: impl Into<Body>) -> Self {
305        Response {
306            status,
307            content_type,
308            body: body.into(),
309            headers: Vec::new(),
310        }
311    }
312
313    /// The same response with one more header.
314    pub fn with_header(mut self, name: &str, value: impl Into<String>) -> Self {
315        self.headers.push((name.to_string(), value.into()));
316        self
317    }
318
319    /// The JSON error body every failure of the projection uses.
320    pub fn error(status: u16, code: &str, message: &str) -> Self {
321        error_response(status, code, message)
322    }
323}
324
325/// The body of every error response.
326#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
327pub struct ErrorBody {
328    /// The one error.
329    pub error: ErrorDetail,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
333/// What went wrong, as the HTTP projection names it.
334pub struct ErrorDetail {
335    /// What kind of failure it is: `invalid_input`, `not_found`, `method_not_allowed`,
336    /// `refused`, `forbidden`, `too_large`, `unavailable` (a surface whose producer has not
337    /// run) or `internal`.
338    pub code: String,
339    /// The reason, for a person.
340    pub message: String,
341}
342
343#[derive(Clone)]
344/// Routes requests to the surfaces this process serves. Cheap to clone: every worker
345/// thread holds one, and the resolution behind it is shared.
346pub struct Router {
347    /// The repository as it is now. Every request takes the current context from here
348    /// before it is answered, which is what keeps a server that has run for hours from
349    /// serving the repository it started in.
350    live: Arc<Live>,
351    version: &'static str,
352    /// The OpenAPI document, rendered once per generation of the repository: it is a
353    /// projection of the registry, and the registry is a projection of the index.
354    openapi: Arc<Memo<Result<String, String>>>,
355    /// MCP over HTTP at the mount its surface declares, when this router serves a shared
356    /// server.
357    mcp: Option<Arc<McpEndpoint>>,
358    /// The Cockpit, when the process located a distribution to serve its assets from.
359    cockpit: Option<Arc<Cockpit>>,
360    ui: Option<Arc<Ui>>,
361    /// The resolved surfaces and their handlers, and the context narrowed to them. Built
362    /// on first use, because the builder learns what this process offers after `new`, and
363    /// again for each generation, because the topology is read out of the repository.
364    served: Arc<Memo<Result<Resolution, String>>>,
365    /// Which git repository the served checkout belongs to, asked once: the index route
366    /// answers it on every probe, and a probe must not cost a subprocess.
367    git: Arc<std::sync::OnceLock<Option<crate::repository::GitIdentity>>>,
368}
369
370/// Which checkout a router is answering for, read from the repository model: the identity
371/// the home page, its JSON projection and the API shell all name, so that a page fetched
372/// from one server cannot be mistaken for another's (I1031). None of it is a path.
373struct ServedCheckout {
374    repository: String,
375    id: String,
376    git: Option<crate::repository::GitIdentity>,
377    branch: Option<String>,
378    head: Option<String>,
379}
380
381impl ServedCheckout {
382    fn as_home(&self) -> home::Checkout<'_> {
383        home::Checkout {
384            id: &self.id,
385            git_repository: self.git.as_ref().map(|g| g.id.as_str()),
386            linked: self.git.as_ref().is_some_and(|g| g.linked),
387            branch: self.branch.as_deref(),
388            head: self.head.as_deref(),
389        }
390    }
391}
392
393/// What one router serves: the bound surfaces, and the context every capability call is
394/// given so that `web.surfaces` answers with what this process actually serves.
395struct Resolution {
396    served: Served,
397    ctx: Arc<Context>,
398}
399
400/// The surfaces a router serves, held for as long as its reader needs them.
401///
402/// A resolution belongs to one generation of the repository and is replaced when the
403/// repository moves under the process, so it cannot be lent out as a bare reference tied
404/// to the router. This holds the generation alive while the caller reads it, and derefs to
405/// the surfaces themselves so that a caller writes `router.served()?.summary(url)` exactly
406/// as it did when the index was immutable.
407///
408/// ```
409/// # use majordomus_cli::http::Router;
410/// # use majordomus_cli::http::router::ServedRef;
411/// # fn example(router: &Router) {
412/// // the surfaces, read through the generation that resolved them
413/// let served: ServedRef = router.served().expect("a topology that resolves");
414/// assert!(served.topology().surfaces.iter().any(|s| s.id == "home"));
415/// # }
416/// ```
417pub struct ServedRef {
418    resolution: Arc<Result<Resolution, String>>,
419}
420
421impl std::ops::Deref for ServedRef {
422    type Target = Served;
423
424    fn deref(&self) -> &Served {
425        match &*self.resolution {
426            Ok(resolution) => &resolution.served,
427            // `served()` is the only constructor and it builds one only from `Ok`
428            Err(reason) => unreachable!("a resolved surface set that did not resolve: {reason}"),
429        }
430    }
431}
432
433impl std::fmt::Debug for ServedRef {
434    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435        f.debug_struct("ServedRef").finish()
436    }
437}
438
439impl Router {
440    /// A router over a view of the repository, without `/mcp` and without the Cockpit.
441    ///
442    /// `live` is either the [`Live`] the shared server follows the repository with or a
443    /// plain [`Arc<Context>`], which becomes a pinned view: a one-shot process and a
444    /// benchmark have nothing to follow and pay nothing to prove it.
445    pub fn new(live: impl IntoLive, version: &'static str) -> Self {
446        crate::perf::Counters::bump(&crate::perf::COUNTERS.http_projection_builds);
447        Router {
448            live: live.into_live(),
449            version,
450            openapi: Arc::new(Memo::default()),
451            mcp: None,
452            cockpit: None,
453            ui: None,
454            served: Arc::new(Memo::default()),
455            git: Arc::new(std::sync::OnceLock::new()),
456        }
457    }
458
459    /// The same router, serving the Cockpit's pages with its assets read from `share_dir`.
460    pub fn with_cockpit(mut self, share_dir: Option<&std::path::Path>) -> Self {
461        self.cockpit = Some(Arc::new(Cockpit::new(
462            Arc::clone(&self.live),
463            self.version,
464            share_dir,
465        )));
466        self
467    }
468
469    /// The same router, serving the UI's pages (ADR 0135) with its assets read from
470    /// `share_dir`. Without a distribution the screens are still complete HTML, unstyled;
471    /// either way the UI becomes one of the surfaces the topology says this process serves.
472    ///
473    /// ```
474    /// # use majordomus_cli::http::Router;
475    /// # fn example(router: Router) {
476    /// let router = router.with_ui(None);
477    /// let served = router.served().expect("a topology that resolves");
478    /// assert!(served.topology().surfaces.iter().any(|s| s.id == majordomus_cli::ui::SURFACE));
479    /// # }
480    /// ```
481    pub fn with_ui(mut self, share_dir: Option<&std::path::Path>) -> Self {
482        self.ui = Some(Arc::new(Ui::new(
483            Arc::clone(&self.live),
484            self.version,
485            share_dir,
486        )));
487        self
488    }
489
490    /// The same router, serving MCP over HTTP through `endpoint`.
491    pub fn with_mcp(mut self, endpoint: Arc<McpEndpoint>) -> Self {
492        self.mcp = Some(endpoint);
493        self
494    }
495
496    /// What this process offers, as the topology's feature gates read it.
497    fn runtime(&self) -> Runtime {
498        Runtime {
499            mcp: self.mcp.is_some(),
500            cockpit: self.cockpit.is_some(),
501            ui: self.ui.is_some(),
502        }
503    }
504
505    /// The resolved surfaces, narrowed and validated once per generation of the
506    /// repository.
507    ///
508    /// Once per *generation* and not once per process: the topology is discovered from the
509    /// site's configuration and the generated web root, both of which are files in the
510    /// repository, so a checkout that moved may serve a different set of surfaces.
511    fn resolution(&self) -> Arc<Result<Resolution, String>> {
512        self.resolution_of(self.live.view())
513    }
514
515    /// The resolution of the generation there is, without waiting for a reload: what a
516    /// page of the UI is routed by (ADR 0142). The UI says itself when the repository has
517    /// moved past what it shows; every other surface is routed by [`Router::resolution`].
518    fn resolution_without_waiting(&self) -> Arc<Result<Resolution, String>> {
519        self.resolution_of(self.live.view_without_waiting().0)
520    }
521
522    fn resolution_of(&self, view: crate::live::View) -> Arc<Result<Resolution, String>> {
523        self.served.get_or_init(view.generation, || {
524            let root = std::path::Path::new(&view.ctx.index.repository.root);
525            Served::resolve(&view.ctx.web, root, self.runtime())
526                .map(|served| Resolution {
527                    ctx: Arc::new(view.ctx.with_web(served.shared())),
528                    served,
529                })
530                // the reason alone: `served()` puts it back into the same error, and a
531                // message that names its own kind twice reads as a bug in the tool
532                .map_err(|e| match e {
533                    crate::error::Error::InvalidSurface { reason, .. } => reason,
534                    other => other.to_string(),
535                })
536        })
537    }
538
539    /// The surfaces this router serves, for a caller that wants to describe them: the
540    /// server's startup line, and the tests.
541    ///
542    /// The answer is the resolution, not a fresh discovery — a router that could not
543    /// resolve its topology never answered a request either, and says the same thing here
544    /// that it says to a client.
545    pub fn served(&self) -> crate::error::Result<ServedRef> {
546        let resolution = self.resolution();
547        match &*resolution {
548            Ok(_) => Ok(ServedRef { resolution }),
549            Err(reason) => Err(crate::error::Error::InvalidSurface {
550                surface: "topology".into(),
551                reason: reason.clone(),
552            }),
553        }
554    }
555
556    /// Does this request open the live channel, and may it?
557    ///
558    /// The server asks before it reads a body or writes a response, because an upgrade is
559    /// the one thing a router cannot answer: it hands the socket over. `None` means the
560    /// request is not for the live channel at all and is routed normally.
561    pub fn websocket(&self, req: &Request) -> Option<Result<events::Accepted, Response>> {
562        if req.path != events::PATH {
563            return None;
564        }
565        if !super::ws::is_upgrade(req) {
566            return None;
567        }
568        // the upgrade is answered before `handle`, so it is refused here for the reason
569        // `handle` refuses every other request a DNS-rebinding page sends: otherwise a page
570        // whose domain was made to resolve to this machine follows every execution
571        if let Some(refusal) = Self::rebound(req) {
572            return Some(Err(refusal));
573        }
574        // a process whose topology does not resolve serves nothing, the live channel
575        // included: the same answer every other path gets
576        let resolution = self.resolution();
577        let ctx = match &*resolution {
578            Ok(resolution) => &resolution.ctx,
579            Err(reason) => return Some(Err(Response::error(500, "internal", reason))),
580        };
581        Some(events::accept(
582            ctx.executions.store(),
583            req,
584            &snapshot_route(ctx),
585        ))
586    }
587
588    fn openapi(&self) -> Response {
589        let view = self.live.view();
590        let rendered = self.openapi.get_or_init(view.generation, || {
591            let cases = CaseContext {
592                index: &view.ctx.index,
593            };
594            openapi::document(&view.ctx.registry, self.version, Some(&cases))
595                .map(|d| openapi::render(&d))
596        });
597        match &*rendered {
598            Ok(text) => Response::new(200, "application/json", text.clone()),
599            Err(e) => error_response(500, "internal", e),
600        }
601    }
602
603    /// Answer one request: the surface that owns its path, or nothing.
604    ///
605    /// A request that carries an `Origin` header came from a page in a browser. A browser
606    /// cannot read a cross-origin response without the headers this server never sends,
607    /// so a read is already contained; a request that changes something is not, and one
608    /// from another origin is refused before it reaches a handler. A client that is not a
609    /// browser sends no `Origin` and is unaffected.
610    pub fn handle(&self, req: &Request) -> Response {
611        if let Some(refusal) = Self::rebound(req) {
612            return refusal;
613        }
614        if req.method != "GET" && req.method != "HEAD" {
615            if let Some(refusal) = self.foreign_origin(req) {
616                return refusal;
617            }
618        }
619        // a page of the UI is drawn from the generation there is and never waits for the next
620        // one; every other request waits for the generation its caller saw
621        let page = req.method == "GET"
622            && (req.path == crate::ui::PREFIX
623                || req.path.starts_with(&format!("{}/", crate::ui::PREFIX)));
624        let held = if page {
625            self.resolution_without_waiting()
626        } else {
627            self.resolution()
628        };
629        let resolution = match &*held {
630            Ok(resolution) => resolution,
631            Err(reason) => return error_response(500, "internal", reason),
632        };
633        let Some((surface, bound)) = resolution.served.owner(&req.path) else {
634            return error_response(
635                404,
636                "not_found",
637                &format!(
638                    "no surface owns {}; what this process serves is listed at / and at {}web/surfaces",
639                    req.path,
640                    crate::capability::model::HttpExposure::PREFIX
641                ),
642            );
643        };
644        // a surface owns its mount and everything under it, which is what a directory and a
645        // prefix want and what a single route does not: `/swagger/anything` is not the
646        // Swagger UI, and answering it as though it were would invent a route nothing
647        // declared. The surfaces that answer one path say so here, once.
648        let exact = req.path == surface.mount.as_str() || req.path == surface.mount.prefix();
649        // the home page is not among them: its mount is the root, which owns every path
650        // nothing else claims, and it answers those with a 404 that names what is served
651        let single_path = matches!(
652            bound,
653            Bound::Route(Native::OpenApi | Native::Swagger | Native::Mcp | Native::Events)
654        );
655        if single_path && !exact {
656            return error_response(
657                404,
658                "not_found",
659                &format!(
660                    "{} is the whole of the '{}' surface; nothing is served under it. What this process serves is listed at / and at {}web/surfaces",
661                    surface.mount,
662                    surface.id,
663                    crate::capability::model::HttpExposure::PREFIX
664                ),
665            );
666        }
667        match bound {
668            Bound::Directory(files) => {
669                if req.method != "GET" {
670                    return error_response(
671                        405,
672                        "method_not_allowed",
673                        &format!(
674                            "{} is a generated directory; it is read with GET",
675                            surface.mount
676                        ),
677                    );
678                }
679                files.respond(&req.path)
680            }
681            Bound::Route(Native::Home) => self.home(req, resolution),
682            Bound::Route(Native::OpenApi) => self.openapi(),
683            Bound::Route(Native::Swagger) => {
684                let checkout = self.checkout(&resolution.ctx);
685                swagger_response(&checkout.as_home().line(&checkout.repository))
686            }
687            Bound::Route(Native::Mcp) => match &self.mcp {
688                Some(endpoint) => endpoint.handle(req),
689                None => error_response(
690                    500,
691                    "internal",
692                    "the MCP surface is served with no endpoint behind it",
693                ),
694            },
695            // the upgrade itself never reaches here: the server answers it before routing,
696            // because handing the socket over is something only the server can do. What
697            // reaches here is a request that did not ask to be upgraded, and the reply says
698            // what this path is and where the same events are readable over HTTP.
699            Bound::Route(Native::Events) => match events::accept(
700                resolution.ctx.executions.store(),
701                req,
702                &snapshot_route(&resolution.ctx),
703            ) {
704                Ok(_) => Response::error(
705                    500,
706                    "internal",
707                    "a WebSocket upgrade reached the router; the server answers it before routing",
708                ),
709                Err(response) => response,
710            },
711            Bound::Route(Native::Cockpit) => match &self.cockpit {
712                Some(cockpit) => cockpit.handle(req),
713                None => error_response(
714                    500,
715                    "internal",
716                    "the Cockpit surface is served with no Cockpit behind it",
717                ),
718            },
719            Bound::Route(Native::Ui) => match &self.ui {
720                Some(ui) => {
721                    Ui::warm_once(ui);
722                    ui.handle(req)
723                }
724                None => error_response(
725                    500,
726                    "internal",
727                    "the UI surface is served with no UI behind it",
728                ),
729            },
730            Bound::Route(Native::Api) => {
731                let prefix = crate::capability::model::HttpExposure::PREFIX;
732                if req.path == prefix || req.path == prefix.trim_end_matches('/') {
733                    return self.routes(&resolution.ctx);
734                }
735                self.capability(req, &resolution.ctx)
736            }
737        }
738    }
739
740    /// `/`: the home page for a browser, the same topology as JSON for everything else.
741    ///
742    /// The root surface owns every path nothing else claims, which is how a request for a
743    /// path no surface declares reaches the most useful 404 this server can give: one from
744    /// a process that knows what it does serve.
745    fn home(&self, req: &Request, resolution: &Resolution) -> Response {
746        if req.path != "/" {
747            return error_response(
748                404,
749                "not_found",
750                &format!(
751                    "no surface owns {}; what this process serves is listed at / and at {}web/surfaces",
752                    req.path,
753                    crate::capability::model::HttpExposure::PREFIX
754                ),
755            );
756        }
757        if req.method != "GET" {
758            return error_response(405, "method_not_allowed", "the home page is read with GET");
759        }
760        let topology = resolution.served.topology();
761        let checkout = self.checkout(&resolution.ctx);
762        if prefers_html(req) {
763            let identity = home::Identity {
764                version: self.version,
765                summary: crate::about::SUMMARY,
766                repository: repository_name(&resolution.ctx.index.repository.root),
767                revision: match &resolution.ctx.index.repository.git {
768                    crate::git::GitState::Available(info) => info.head.as_deref(),
769                    crate::git::GitState::Unavailable { .. } => None,
770                },
771                capabilities: resolution.ctx.registry.summary().total,
772                checkout: checkout.as_home(),
773            };
774            let ready = |id: &str| {
775                if resolution.served.ready(id) {
776                    home::Availability::Ready
777                } else {
778                    home::Availability::NotBuilt
779                }
780            };
781            return Response::new(
782                200,
783                "text/html; charset=utf-8",
784                home::page(topology, &identity, &ready),
785            );
786        }
787        let surfaces: Vec<Value> = topology
788            .surfaces
789            .iter()
790            .map(|s| {
791                json!({
792                    "id": s.id,
793                    "title": s.title,
794                    "path": s.mount.as_str(),
795                    "category": s.category.to_string(),
796                    "visibility": s.visibility.to_string(),
797                    "kind": s.kind.to_string(),
798                    "ready": resolution.served.ready(&s.id),
799                })
800            })
801            .collect();
802        let git = checkout.git.as_ref();
803        // whether this process is still the checkout's server. A server whose lease was
804        // taken over keeps serving the peers it already has, so it goes on answering here,
805        // and every other field is as true of it as of the current server. This is the one
806        // that tells them apart; `lease::probe` reads it under the same name, which is why
807        // the name is the constant and not a literal.
808        let mut index = json!({
809                "name": "majordomus",
810                "version": self.version,
811                // the commit this executable was built from, the value `health.live` and
812                // `health.ready` answer under the same names: a checkout's server that
813                // answers from another commit than the checkout's HEAD is serving another
814                // tree, and a reader can only say so if the server names its own
815                "commit": crate::COMMIT,
816                "dirty": crate::DIRTY,
817                // and whether that code still exists: null while the file this process was
818                // started from is the file on disk, the reason once it has been replaced —
819                // the same answer `health.ready` gives, so a client reading only this
820                // document can tell a current server from one serving yesterday's code
821                "stale": crate::lease::serving_replaced_code(),
822                "description": crate::about::SUMMARY,
823                "reference": crate::about::REFERENCE_URL,
824                // the repository's name and not its path: this answer is served to whoever
825                // can reach the socket, and where the checkout sits on the host is of no
826                // use to them and of some use to somebody else. The HTML page has always
827                // withheld it; the two projections of one surface now agree.
828                "repository": repository_name(&resolution.ctx.index.repository.root),
829                // which repository, without saying where it is: a second process that
830                // already knows the root computes the same value and knows this server is
831                // its own (crate::repository::identity)
832                "repository_id": checkout.id,
833                // and which git repository that checkout belongs to: one value for every
834                // worktree of it, so that a reader can tell two servers of one repository
835                // from the servers of two (crate::repository::git_identity)
836                "git_repository_id": git.map(|g| g.id.clone()),
837                "linked_worktree": git.is_some_and(|g| g.linked),
838                // the branch and commit the served answers were read from, as the index
839                // read them: two servers of one repository can serve two trees (I1031)
840                "branch": checkout.branch,
841                "head": checkout.head,
842                "surfaces": surfaces,
843        });
844        index[crate::lease::LEASEHOLDER_KEY] = json!(!crate::lease::was_lost());
845        json_response(200, &index)
846    }
847
848    /// Which checkout this router answers for, from the repository model the context was
849    /// built from: the checkout's identity, its git repository's, the branch and commit the
850    /// index read. No second inspection: the identity is the repository module's, asked
851    /// once per process, and the branch and commit are the index's own.
852    fn checkout(&self, ctx: &Context) -> ServedCheckout {
853        let root = std::path::Path::new(&ctx.index.repository.root);
854        let git = self
855            .git
856            .get_or_init(|| crate::repository::git_identity(root))
857            .clone();
858        let (branch, head) = match &ctx.index.repository.git {
859            crate::git::GitState::Available(info) => (info.branch.clone(), info.head.clone()),
860            crate::git::GitState::Unavailable { .. } => (None, None),
861        };
862        ServedCheckout {
863            repository: repository_name(&ctx.index.repository.root).to_string(),
864            id: crate::repository::identity(root),
865            git,
866            branch,
867            head,
868        }
869    }
870
871    /// The refusal for a state-changing request from another origin, when there is one.
872    /// The allowed origins are this server's own: whatever host the request was addressed
873    /// to. Nothing else is configured, because nothing else should be able to change this
874    /// process from a browser.
875    fn foreign_origin(&self, req: &Request) -> Option<Response> {
876        let origin = req.header("origin")?;
877        // `null` is what a sandboxed frame or a `file://` page sends; neither is this server
878        let host = req.header("host").unwrap_or_default();
879        let allowed = [format!("http://{host}"), format!("https://{host}")];
880        // The origin matching the Host header is only this server's own origin when the Host
881        // cannot be chosen by a page: a DNS-rebinding page names its own domain in both and
882        // matches. So the request must also be addressed by an IP literal or `localhost`,
883        // which no rebinding domain is.
884        if allowed.iter().any(|a| a == origin) && Self::host_is_literal(host) {
885            return None;
886        }
887        tracing::warn!(
888            origin = %crate::redaction::redact_secrets(origin).text,
889            method = %req.method,
890            path = %crate::redaction::redact_secrets(&req.path).text,
891            "a state-changing request from another origin was refused"
892        );
893        Some(error_response(
894            403,
895            "forbidden",
896            &format!(
897                "a {} from origin '{origin}' is refused: this server accepts a state-changing request from a browser only from its own origin",
898                req.method
899            ),
900        ))
901    }
902
903    /// Whether a `Host` header names this machine by an address rather than by a domain a
904    /// third party could point at it: an IPv4 or bracketed IPv6 literal, `localhost` or a
905    /// `*.localhost` name, each with an optional port.
906    /// The refusal for a request a DNS-rebinding page sent, when this is one.
907    ///
908    /// A page on another domain cannot read this server's answers, until its domain is made
909    /// to resolve to this machine: then the browser on this machine sends the page's requests
910    /// here, from loopback, naming the page's domain as the host. Nothing else sends a
911    /// loopback request under a name that is neither an address nor `localhost`, so that
912    /// request is refused before any surface answers it, reads included. A request from
913    /// another host is judged by ADR 0126, and one that names no host is a program's.
914    fn rebound(req: &Request) -> Option<Response> {
915        let from_loopback = req.remote.is_some_and(|ip| ip.to_canonical().is_loopback());
916        let host = req.header("host")?;
917        if !from_loopback || Self::host_is_literal(host) {
918            return None;
919        }
920        tracing::warn!(host = host, path = %req.path, "a loopback request under a foreign host name was refused");
921        Some(error_response(
922            403,
923            "forbidden",
924            &format!(
925                "a request from this machine addressed to '{host}' is refused: this server answers loopback requests only under an address or `localhost`, so a page whose domain was made to resolve here reads nothing"
926            ),
927        ))
928    }
929
930    fn host_is_literal(host: &str) -> bool {
931        let name = if let Some(rest) = host.strip_prefix('[') {
932            match rest.split_once(']') {
933                Some((v6, tail)) if tail.is_empty() || tail.starts_with(':') => {
934                    return v6.parse::<std::net::Ipv6Addr>().is_ok();
935                }
936                _ => return false,
937            }
938        } else {
939            host.rsplit_once(':').map_or(host, |(name, port)| {
940                if port.parse::<u16>().is_ok() {
941                    name
942                } else {
943                    host
944                }
945            })
946        };
947        name.parse::<std::net::Ipv4Addr>().is_ok()
948            || name.eq_ignore_ascii_case("localhost")
949            || name.to_ascii_lowercase().ends_with(".localhost")
950    }
951
952    /// The capability mount itself: every route under it, from the registry that declares
953    /// them.
954    ///
955    /// It exists so that the surface the home page offers is a page a person can open
956    /// rather than a prefix that answers 404, and it is the registry's own list rather
957    /// than a second one — the schemas, the parameters and the examples stay in the
958    /// OpenAPI document, which is generated from the same descriptors.
959    fn routes(&self, ctx: &Arc<Context>) -> Response {
960        // Ordered as routes, then serialised — the same order the Cockpit's table and the
961        // OpenAPI document read in. The previous sort compared `Value::to_string()`, which
962        // is the JSON encoding of the path, quotes included, and had no method tiebreak.
963        let mut declared: Vec<_> = ctx
964            .registry
965            .iter()
966            .filter_map(|c| c.exposure.http.as_ref().map(|http| (c, http)))
967            .collect();
968        declared.sort_by(|(a, ha), (b, hb)| {
969            crate::order::natural_cmp(&ha.path, &hb.path)
970                .then_with(|| crate::order::natural_cmp(ha.method.as_str(), hb.method.as_str()))
971                .then_with(|| crate::order::natural_cmp(a.id.as_str(), b.id.as_str()))
972        });
973        let routes: Vec<Value> = declared
974            .into_iter()
975            .map(|(c, http)| {
976                json!({
977                    "method": http.method.as_str(),
978                    "path": http.path,
979                    "capability": c.id.as_str(),
980                    "title": c.title,
981                })
982            })
983            .collect();
984        json_response(
985            200,
986            &json!({
987                "description": "Every capability with an HTTP exposure, as the registry declares it.",
988                "openapi": swagger::SPEC_PATH,
989                "swagger": swagger::SWAGGER_PATH,
990                "routes": routes,
991            }),
992        )
993    }
994
995    fn capability(&self, req: &Request, ctx: &Arc<Context>) -> Response {
996        let Some(method) = HttpMethod::parse(&req.method) else {
997            return error_response(
998                405,
999                "method_not_allowed",
1000                &format!("method {} is not served", req.method),
1001            );
1002        };
1003        let Some(c) = ctx.registry.by_http(method, &req.path) else {
1004            let other_method = [HttpMethod::Get, HttpMethod::Post]
1005                .into_iter()
1006                .filter(|m| *m != method)
1007                .any(|m| ctx.registry.by_http(m, &req.path).is_some());
1008            return if other_method {
1009                error_response(
1010                    405,
1011                    "method_not_allowed",
1012                    &format!(
1013                        "{} {} is not a route; another method is",
1014                        req.method, req.path
1015                    ),
1016                )
1017            } else {
1018                error_response(
1019                    404,
1020                    "not_found",
1021                    &format!(
1022                        "no route {} {}; the routes are listed at /openapi.json",
1023                        req.method, req.path
1024                    ),
1025                )
1026            };
1027        };
1028        if req.is_remote() && !c.execution.admits_remote() {
1029            let e = CapabilityError::remote(c.id.as_str());
1030            return error_response(403, e.code(), &e.to_string());
1031        }
1032        let input = match method {
1033            HttpMethod::Get => {
1034                let (props, _) = c.input.properties();
1035                let mut obj = serde_json::Map::new();
1036                for (k, raw) in &req.query {
1037                    let property = props
1038                        .iter()
1039                        .find(|(n, _)| n == k)
1040                        .map(|(_, s)| s.clone())
1041                        .unwrap_or(Value::Null);
1042                    match crate::capability::schema::coerce(&property, raw) {
1043                        Ok(v) => {
1044                            obj.insert(k.clone(), v);
1045                        }
1046                        Err(e) => {
1047                            return error_response(
1048                                400,
1049                                "invalid_input",
1050                                &format!("parameter '{k}': {e}"),
1051                            )
1052                        }
1053                    }
1054                }
1055                Value::Object(obj)
1056            }
1057            HttpMethod::Post => {
1058                if req.body.iter().all(u8::is_ascii_whitespace) {
1059                    json!({})
1060                } else {
1061                    match serde_json::from_slice::<Value>(&req.body) {
1062                        Ok(v) if v.is_object() => v,
1063                        Ok(_) => {
1064                            return error_response(
1065                                400,
1066                                "invalid_input",
1067                                "the body must be a JSON object",
1068                            )
1069                        }
1070                        Err(e) => {
1071                            return error_response(
1072                                400,
1073                                "invalid_input",
1074                                &format!("the body is not JSON: {e}"),
1075                            )
1076                        }
1077                    }
1078                }
1079            }
1080        };
1081        // one line per request is debug: a client that does not drain stderr must not be
1082        // able to wedge the workers on a full pipe at the default level
1083        tracing::debug!(capability_id = %c.id, route = %format!("{} {}", req.method, req.path), "http");
1084        match ctx.execute(c.id.as_str(), input) {
1085            Ok(v) => json_response(200, &v),
1086            // the word is the error's own, the same one an MCP tool result carries; only
1087            // the status is this transport's
1088            Err(e) => failed(&e),
1089        }
1090    }
1091}
1092
1093/// A capability's error as an HTTP response: the status is this transport's, the code and
1094/// the message are the error's own.
1095fn failed(e: &CapabilityError) -> Response {
1096    let status = match e {
1097        CapabilityError::InvalidInput(_) => 400,
1098        CapabilityError::NotFound(_) => 404,
1099        CapabilityError::Refused(_) => 422,
1100        CapabilityError::Internal(_) => 500,
1101        CapabilityError::Forbidden(_) => 403,
1102    };
1103    let (CapabilityError::InvalidInput(m)
1104    | CapabilityError::NotFound(m)
1105    | CapabilityError::Refused(m)
1106    | CapabilityError::Internal(m)
1107    | CapabilityError::Forbidden(m)) = e;
1108    error_response(status, e.code(), m)
1109}
1110
1111/// Does this request come from something that would rather have a page than a document?
1112/// Only an explicit `text/html` in `Accept` counts: a client that sends none, or `*/*`,
1113/// gets the JSON index, which is what every existing caller does.
1114///
1115/// ```
1116/// use majordomus_cli::http::{router::prefers_html, Request};
1117/// let plain = Request::parse_target("GET", "/", vec![]);
1118/// assert!(!prefers_html(&plain));
1119/// let browser = plain.clone().with_headers(vec![
1120///     ("Accept".into(), "text/html,application/xhtml+xml,*/*;q=0.8".into()),
1121/// ]);
1122/// assert!(prefers_html(&browser));
1123/// ```
1124pub fn prefers_html(req: &Request) -> bool {
1125    req.header("accept").is_some_and(|accept| {
1126        accept.split(',').any(|part| {
1127            part.split(';')
1128                .next()
1129                .is_some_and(|t| t.trim() == "text/html")
1130        })
1131    })
1132}
1133
1134/// The repository's name: the last component of its root, which is what a person calls it.
1135/// The root itself is a filesystem path and is not put on a page anyone can reach.
1136fn repository_name(root: &str) -> &str {
1137    root.rsplit('/')
1138        .find(|s| !s.is_empty())
1139        .unwrap_or("repository")
1140}
1141
1142/// The Swagger UI shell. `/swagger` and `/swagger/` both answer it: the page loads its
1143/// distribution and the document by absolute path, so neither form can resolve wrongly.
1144/// `checkout` is the line naming the checkout that serves it, already escaped.
1145fn swagger_response(checkout: &str) -> Response {
1146    Response::new(200, "text/html; charset=utf-8", swagger::page_for(checkout))
1147        .with_header("Content-Security-Policy", swagger::csp())
1148        .with_header("X-Content-Type-Options", "nosniff")
1149        .with_header("Referrer-Policy", "no-referrer")
1150}
1151
1152fn json_response(status: u16, v: &Value) -> Response {
1153    Response::new(
1154        status,
1155        "application/json",
1156        serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string()),
1157    )
1158}
1159
1160fn error_response(status: u16, code: &str, message: &str) -> Response {
1161    // an error often echoes what it was asked — a path, a parameter, an input — and what it
1162    // was asked may hold a credential; no answer of this server carries one back
1163    let body = ErrorBody {
1164        error: ErrorDetail {
1165            code: code.into(),
1166            message: crate::redaction::redact_secrets(message).text,
1167        },
1168    };
1169    Response::new(
1170        status,
1171        "application/json",
1172        serde_json::to_string_pretty(&body).unwrap_or_default(),
1173    )
1174}
1175
1176/// Percent-encode everything but the unreserved characters of RFC 3986.
1177pub fn percent_encode(s: &str) -> String {
1178    let mut out = String::with_capacity(s.len());
1179    for b in s.bytes() {
1180        match b {
1181            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
1182                out.push(b as char)
1183            }
1184            _ => out.push_str(&format!("%{b:02X}")),
1185        }
1186    }
1187    out
1188}
1189
1190/// `%XX` and `+` decoding; a malformed escape is kept as it is.
1191pub fn percent_decode(s: &str) -> String {
1192    let bytes = s.as_bytes();
1193    let mut out = Vec::with_capacity(bytes.len());
1194    let mut i = 0;
1195    while i < bytes.len() {
1196        match bytes[i] {
1197            b'+' => out.push(b' '),
1198            b'%' if i + 2 < bytes.len() => {
1199                match u8::from_str_radix(
1200                    std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("zz"),
1201                    16,
1202                ) {
1203                    Ok(b) => {
1204                        out.push(b);
1205                        i += 2;
1206                    }
1207                    Err(_) => out.push(b'%'),
1208                }
1209            }
1210            b => out.push(b),
1211        }
1212        i += 1;
1213    }
1214    String::from_utf8_lossy(&out).into_owned()
1215}
1216
1217/// The route of `executions.get` as the registry declares it: where the live channel tells
1218/// a client to read a value too large for one frame.
1219fn snapshot_route(ctx: &Context) -> String {
1220    ctx.registry
1221        .get("executions.get")
1222        .and_then(|c| c.exposure.http.as_ref())
1223        .map(|h| h.path.clone())
1224        .unwrap_or_default()
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229    use super::*;
1230
1231    /// A topology resolved while the UI was there, answered after it is gone, says so
1232    /// rather than serving an empty page or another surface.
1233    #[test]
1234    fn a_ui_route_with_no_ui_behind_it_says_so() {
1235        let repo = crate::synthetic::SyntheticRepository::new(crate::synthetic::Shape::default())
1236            .expect("a synthetic repository");
1237        let mut router = Router::new(repo.context().expect("a context"), "test").with_ui(None);
1238        assert!(
1239            router.served().is_ok(),
1240            "the topology resolves with the UI in it"
1241        );
1242        router.ui = None;
1243        let r = router.handle(&Request::parse_target("GET", crate::ui::PREFIX, Vec::new()));
1244        assert_eq!(r.status, 500, "{}", r.body.text());
1245        assert!(
1246            r.body.text().contains("no UI behind it"),
1247            "{}",
1248            r.body.text()
1249        );
1250    }
1251
1252    #[test]
1253    fn every_capability_error_has_its_status_and_keeps_its_word() {
1254        for (e, status, code) in [
1255            (
1256                CapabilityError::InvalidInput("m".into()),
1257                400,
1258                "invalid_input",
1259            ),
1260            (CapabilityError::NotFound("m".into()), 404, "not_found"),
1261            (CapabilityError::Refused("m".into()), 422, "refused"),
1262            (CapabilityError::Internal("m".into()), 500, "internal"),
1263            (CapabilityError::remote("plan.transition"), 403, "forbidden"),
1264        ] {
1265            let r = failed(&e);
1266            assert_eq!(r.status, status, "{e:?}");
1267            let body: serde_json::Value = serde_json::from_str(&r.body.text()).unwrap();
1268            assert_eq!(body["error"]["code"], code, "{body}");
1269        }
1270        let body: serde_json::Value = serde_json::from_str(
1271            &failed(&CapabilityError::remote("plan.transition"))
1272                .body
1273                .text(),
1274        )
1275        .unwrap();
1276        assert!(body.to_string().contains("plan.transition"), "{body}");
1277    }
1278
1279    /// The live channel is upgraded before `handle` is reached, so the DNS-rebinding guard
1280    /// `handle` applies must be applied to the upgrade as well: a page whose domain resolves
1281    /// here names that domain in both Host and Origin, which match, from loopback.
1282    #[test]
1283    fn a_rebound_page_cannot_open_the_live_channel() {
1284        let repo = crate::synthetic::SyntheticRepository::new(crate::synthetic::Shape::default())
1285            .expect("a synthetic repository");
1286        let router = Router::new(repo.context().expect("a context"), "test").with_ui(None);
1287        let upgrade = |host: &str| {
1288            Request::parse_target("GET", events::PATH, Vec::new())
1289                .with_headers(vec![
1290                    ("Upgrade".into(), "websocket".into()),
1291                    ("Connection".into(), "Upgrade".into()),
1292                    (
1293                        "Sec-WebSocket-Key".into(),
1294                        "dGhlIHNhbXBsZSBub25jZQ==".into(),
1295                    ),
1296                    ("Sec-WebSocket-Version".into(), "13".into()),
1297                    ("Host".into(), host.into()),
1298                    ("Origin".into(), format!("http://{host}")),
1299                ])
1300                .with_remote(Some("127.0.0.1".parse().unwrap()))
1301        };
1302        match router.websocket(&upgrade("rebound.example:8741")) {
1303            Some(Err(r)) => {
1304                assert_eq!(r.status, 403, "{}", r.body.text());
1305                assert!(
1306                    r.body.text().contains("rebound.example"),
1307                    "{}",
1308                    r.body.text()
1309                );
1310            }
1311            Some(Ok(_)) => panic!("a rebound page was handed the live channel"),
1312            None => panic!("an upgrade of the live channel was not recognised"),
1313        }
1314        assert!(
1315            matches!(router.websocket(&upgrade("127.0.0.1:8741")), Some(Ok(_))),
1316            "this server's own page still opens it"
1317        );
1318    }
1319
1320    #[test]
1321    fn only_an_address_or_localhost_is_a_host_no_rebinding_domain_can_name() {
1322        for host in [
1323            "127.0.0.1",
1324            "127.0.0.1:8742",
1325            "192.168.1.20:8742",
1326            "[::1]:8742",
1327            "[::1]",
1328            "localhost",
1329            "LOCALHOST:80",
1330            "cockpit.localhost:8742",
1331        ] {
1332            assert!(Router::host_is_literal(host), "{host} names this machine");
1333        }
1334        for host in [
1335            "",
1336            "evil.example",
1337            "evil.example:8742",
1338            "127.0.0.1.evil.example",
1339            "localhost.evil.example:8742",
1340            "[::1",
1341            "[::1]x",
1342            "[evil]:8742",
1343        ] {
1344            assert!(!Router::host_is_literal(host), "{host} could be rebound");
1345        }
1346    }
1347
1348    #[test]
1349    fn a_body_is_text_or_bytes_and_says_so_the_same_way() {
1350        let text = Body::from("hello".to_string());
1351        assert_eq!(text.len(), 5);
1352        assert!(!text.is_empty());
1353        assert_eq!(text.as_bytes(), b"hello");
1354        assert_eq!(text, *"hello");
1355        assert_eq!(format!("{text}"), "hello");
1356        assert!(matches!(text.text(), std::borrow::Cow::Borrowed(_)));
1357
1358        let bytes = Body::from(vec![0x89, b'P', b'N', b'G']);
1359        assert_eq!(bytes.len(), 4);
1360        assert_eq!(bytes.as_bytes()[0], 0x89);
1361        assert_ne!(bytes, *"PNG");
1362        // a file that is not UTF-8 is passed through unchanged and only repaired when a
1363        // diagnostic asks for text
1364        assert!(matches!(bytes.text(), std::borrow::Cow::Owned(_)));
1365        assert!(Body::from(Vec::new()).is_empty());
1366        assert_eq!(Body::from("a"), Body::Text("a".into()));
1367    }
1368
1369    #[test]
1370    fn a_response_carries_the_headers_it_was_given() {
1371        let r = Response::new(200, "text/plain; charset=utf-8", "hi".to_string())
1372            .with_header("Cache-Control", "no-cache");
1373        assert_eq!(r.status, 200);
1374        assert_eq!(r.body, *"hi");
1375        assert!(r
1376            .headers
1377            .iter()
1378            .any(|(k, v)| k == "Cache-Control" && v == "no-cache"));
1379
1380        let e = Response::error(404, "not_found", "nothing here");
1381        assert_eq!(e.status, 404);
1382        assert_eq!(e.content_type, "application/json");
1383        let parsed: ErrorBody = serde_json::from_str(&e.body.text()).expect("errors are JSON");
1384        assert_eq!(parsed.error.code, "not_found");
1385        assert_eq!(parsed.error.message, "nothing here");
1386    }
1387
1388    #[test]
1389    fn the_repository_is_named_and_its_path_is_not() {
1390        assert_eq!(
1391            repository_name("/a/b/prismatic-majordomus"),
1392            "prismatic-majordomus"
1393        );
1394        assert_eq!(repository_name("/a/b/repo/"), "repo");
1395        assert_eq!(repository_name("/"), "repository");
1396        assert_eq!(repository_name(""), "repository");
1397    }
1398
1399    #[test]
1400    fn the_swagger_shell_is_the_same_page_however_it_is_reached() {
1401        let page = swagger_response("<p class=\"mj-checkout\">checkout 0123456789ab</p>");
1402        assert_eq!(page.status, 200);
1403        assert!(page.content_type.starts_with("text/html"));
1404        assert!(page.body.text().contains("/openapi.json"));
1405        assert!(
1406            page.body.text().contains("checkout 0123456789ab"),
1407            "the shell names its checkout"
1408        );
1409    }
1410
1411    #[test]
1412    fn only_an_explicit_html_accept_asks_for_a_page() {
1413        let plain = Request::parse_target("GET", "/", vec![]);
1414        assert!(!prefers_html(&plain));
1415        for accept in [
1416            "text/html",
1417            "text/html,application/xhtml+xml",
1418            "application/json, text/html;q=0.9",
1419        ] {
1420            let r = plain
1421                .clone()
1422                .with_headers(vec![("Accept".into(), accept.into())]);
1423            assert!(prefers_html(&r), "{accept}");
1424        }
1425        for accept in ["*/*", "application/json", "text/plain"] {
1426            let r = plain
1427                .clone()
1428                .with_headers(vec![("Accept".into(), accept.into())]);
1429            assert!(!prefers_html(&r), "{accept}");
1430        }
1431    }
1432
1433    #[test]
1434    fn a_request_binds_the_way_the_projection_says_and_reads_back_the_same() {
1435        let get = Request::bind(
1436            HttpMethod::Get,
1437            "/api/v1/search",
1438            &json!({ "query": "a b", "limit": 2, "kind": null }),
1439        );
1440        assert_eq!(get.target(), "/api/v1/search?query=a%20b&limit=2");
1441        let back = Request::parse_target("GET", &get.target(), vec![]);
1442        assert_eq!(back.query, get.query);
1443        assert_eq!(back.path, get.path);
1444
1445        let post = Request::bind(HttpMethod::Post, "/api/v1/x", &json!({ "a": 1 }));
1446        assert_eq!(post.target(), "/api/v1/x");
1447        assert_eq!(post.body, br#"{"a":1}"#);
1448    }
1449
1450    #[test]
1451    fn target_parsing_and_decoding() {
1452        let r = Request::parse_target(
1453            "GET",
1454            "/api/v1/search?query=Git%20is%20the%20authority&limit=3&x=a%2Fb",
1455            vec![],
1456        );
1457        assert_eq!(r.path, "/api/v1/search");
1458        assert_eq!(
1459            r.query,
1460            vec![
1461                ("query".into(), "Git is the authority".into()),
1462                ("limit".into(), "3".into()),
1463                ("x".into(), "a/b".into())
1464            ]
1465        );
1466        assert_eq!(percent_decode("a+b%41%"), "a bA%");
1467        assert_eq!(Request::parse_target("GET", "/", vec![]).query, vec![]);
1468    }
1469}