1use 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#[derive(Debug, Clone)]
32pub struct Request {
33 pub method: String,
35 pub path: String,
37 pub query: Vec<(String, String)>,
39 pub headers: Vec<(String, String)>,
41 pub body: Vec<u8>,
43 pub remote: Option<std::net::IpAddr>,
46}
47
48impl Request {
49 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 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 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 pub fn with_headers(mut self, headers: Vec<(String, String)>) -> Self {
154 self.headers = headers;
155 self
156 }
157
158 pub fn with_remote(mut self, address: Option<std::net::IpAddr>) -> Self {
166 self.remote = address;
167 self
168 }
169
170 pub fn is_remote(&self) -> bool {
187 self.remote
188 .is_some_and(|ip| !ip.to_canonical().is_loopback())
189 }
190
191 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#[derive(Debug, Clone, PartialEq, Eq)]
211pub enum Body {
212 Text(String),
214 Bytes(Vec<u8>),
216}
217
218impl Body {
219 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 pub fn len(&self) -> usize {
229 self.as_bytes().len()
230 }
231
232 pub fn is_empty(&self) -> bool {
234 self.len() == 0
235 }
236
237 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)]
290pub struct Response {
292 pub status: u16,
294 pub content_type: &'static str,
296 pub body: Body,
298 pub headers: Vec<(String, String)>,
300}
301
302impl Response {
303 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 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 pub fn error(status: u16, code: &str, message: &str) -> Self {
321 error_response(status, code, message)
322 }
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
327pub struct ErrorBody {
328 pub error: ErrorDetail,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
333pub struct ErrorDetail {
335 pub code: String,
339 pub message: String,
341}
342
343#[derive(Clone)]
344pub struct Router {
347 live: Arc<Live>,
351 version: &'static str,
352 openapi: Arc<Memo<Result<String, String>>>,
355 mcp: Option<Arc<McpEndpoint>>,
358 cockpit: Option<Arc<Cockpit>>,
360 ui: Option<Arc<Ui>>,
361 served: Arc<Memo<Result<Resolution, String>>>,
365 git: Arc<std::sync::OnceLock<Option<crate::repository::GitIdentity>>>,
368}
369
370struct 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
393struct Resolution {
396 served: Served,
397 ctx: Arc<Context>,
398}
399
400pub 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 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 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 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 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 pub fn with_mcp(mut self, endpoint: Arc<McpEndpoint>) -> Self {
492 self.mcp = Some(endpoint);
493 self
494 }
495
496 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 fn resolution(&self) -> Arc<Result<Resolution, String>> {
512 self.resolution_of(self.live.view())
513 }
514
515 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 .map_err(|e| match e {
533 crate::error::Error::InvalidSurface { reason, .. } => reason,
534 other => other.to_string(),
535 })
536 })
537 }
538
539 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 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 if let Some(refusal) = Self::rebound(req) {
572 return Some(Err(refusal));
573 }
574 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 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 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 let exact = req.path == surface.mount.as_str() || req.path == surface.mount.prefix();
649 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 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 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 let mut index = json!({
809 "name": "majordomus",
810 "version": self.version,
811 "commit": crate::COMMIT,
816 "dirty": crate::DIRTY,
817 "stale": crate::lease::serving_replaced_code(),
822 "description": crate::about::SUMMARY,
823 "reference": crate::about::REFERENCE_URL,
824 "repository": repository_name(&resolution.ctx.index.repository.root),
829 "repository_id": checkout.id,
833 "git_repository_id": git.map(|g| g.id.clone()),
837 "linked_worktree": git.is_some_and(|g| g.linked),
838 "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 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 fn foreign_origin(&self, req: &Request) -> Option<Response> {
876 let origin = req.header("origin")?;
877 let host = req.header("host").unwrap_or_default();
879 let allowed = [format!("http://{host}"), format!("https://{host}")];
880 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 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 fn routes(&self, ctx: &Arc<Context>) -> Response {
960 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 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 Err(e) => failed(&e),
1089 }
1090 }
1091}
1092
1093fn 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
1111pub 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
1134fn repository_name(root: &str) -> &str {
1137 root.rsplit('/')
1138 .find(|s| !s.is_empty())
1139 .unwrap_or("repository")
1140}
1141
1142fn 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 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
1176pub 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
1190pub 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
1217fn 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 #[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 #[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 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}