mkit_server/hooks/channel.rs
1//! The transport a hook call travels over (SPEC-SERVER §§6.1, 7.3).
2//!
3//! Core builds, signs and validates hook traffic; a channel only moves bytes.
4//! The native adapter's HTTPS client (WP-3.8) and the Workers service binding
5//! (WP-3.9) implement [`HookChannel`]; tests use an in-memory one.
6
7use core::time::Duration;
8
9use zeroize::Zeroizing;
10
11use crate::error::Redacted;
12use crate::rt::{MaybeSend, MaybeSync};
13
14/// One Connect unary call, ready to send.
15///
16/// The body may carry admission credentials, so [`core::fmt::Debug`] prints
17/// only its length and the body is wiped when dropped. Channels must not log
18/// it.
19#[non_exhaustive]
20pub struct HookRequest {
21 /// The full Connect path, e.g. `/mkit.server.hooks.v1.HooksService/Admit`;
22 /// the channel appends it to its base URL and does not follow redirects.
23 pub procedure: &'static str,
24 /// Every header to send: `Content-Type`, `Connect-Protocol-Version` and,
25 /// on a signed channel, the eight `X-Mkit-Hook-*` headers.
26 pub headers: Vec<(&'static str, String)>,
27 /// The exact JSON body; the signature covers these bytes.
28 pub body: Zeroizing<Vec<u8>>,
29 /// How long the call may take. Core also enforces it, so a channel that
30 /// can cancel the underlying request should.
31 pub timeout: Duration,
32 /// Stop reading the response after this many bytes plus one. Return what
33 /// was read with the real status, so core sees the oversize itself and a
34 /// 2xx to a delivery still acknowledges (SPEC-SERVER §8); report
35 /// [`ChannelError::TooLarge`] only when no status is known. Core re-checks
36 /// the length it receives either way.
37 pub max_response_bytes: usize,
38}
39
40impl HookRequest {
41 /// A request, for a channel's own tests.
42 #[must_use]
43 pub fn new(
44 procedure: &'static str,
45 headers: Vec<(&'static str, String)>,
46 body: Vec<u8>,
47 timeout: Duration,
48 max_response_bytes: usize,
49 ) -> Self {
50 Self {
51 procedure,
52 headers,
53 body: Zeroizing::new(body),
54 timeout,
55 max_response_bytes,
56 }
57 }
58}
59
60impl core::fmt::Debug for HookRequest {
61 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
62 f.debug_struct("HookRequest")
63 .field("procedure", &self.procedure)
64 .field("body_bytes", &self.body.len())
65 .field("timeout", &self.timeout)
66 .finish_non_exhaustive()
67 }
68}
69
70/// A hook's HTTP response, whatever its status.
71#[non_exhaustive]
72pub struct HookResponse {
73 /// The HTTP status.
74 pub status: u16,
75 /// The `Content-Type` header, if any.
76 pub content_type: Option<String>,
77 /// At most `max_response_bytes + 1` bytes of the body.
78 pub body: Vec<u8>,
79}
80
81impl HookResponse {
82 /// A response with `status`, `content_type` and `body`.
83 #[must_use]
84 pub fn new(status: u16, content_type: Option<String>, body: Vec<u8>) -> Self {
85 Self {
86 status,
87 content_type,
88 body,
89 }
90 }
91}
92
93impl core::fmt::Debug for HookResponse {
94 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
95 f.debug_struct("HookResponse")
96 .field("status", &self.status)
97 .field("body_bytes", &self.body.len())
98 .finish_non_exhaustive()
99 }
100}
101
102/// Why a channel produced no response. Every variant means "the hook did not
103/// answer": the adapter fails closed (SPEC-SERVER §8).
104#[derive(Debug, Clone, thiserror::Error)]
105#[non_exhaustive]
106pub enum ChannelError {
107 /// The channel gave up waiting.
108 #[error("hook call timed out")]
109 Timeout,
110 /// The response exceeded `max_response_bytes` and its status is unknown.
111 #[error("hook response too large")]
112 TooLarge,
113 /// Connection, TLS, binding or other failure. Core never prints the text.
114 #[error("hook transport failed")]
115 Transport(Redacted),
116}
117
118/// A route to one hook service.
119pub trait HookChannel: MaybeSend + MaybeSync {
120 /// The hook endpoint's canonical origin, the `<audience>` a signature
121 /// binds (SPEC-SERVER §7.1). `None` for a channel with no origin, which is
122 /// then only usable unsigned and isolated. Plain `http://` is accepted
123 /// only for a loopback host (§6.1); any other channel must use TLS with
124 /// certificate verification and must not follow redirects.
125 fn audience(&self) -> Option<&str>;
126
127 /// Whether this is a platform service binding unreachable from the public
128 /// internet, the only channel that may skip signing (SPEC-SERVER §7.3).
129 fn isolated(&self) -> bool {
130 false
131 }
132
133 /// Send `request` and return the hook's response.
134 ///
135 /// # Errors
136 /// [`ChannelError`] when there is no response to return.
137 fn call(
138 &self,
139 request: HookRequest,
140 ) -> impl core::future::Future<Output = Result<HookResponse, ChannelError>> + MaybeSend;
141}