1use super::*;
12#[cfg(test)]
13use appcore_transport::parse_response;
14use appcore_transport::{
15 send, HttpClientConfig, HttpHeader, HttpRequest, HttpScheme as SharedHttpScheme,
16 HttpTarget as SharedHttpTarget, TransportError,
17};
18use zeroize::Zeroizing;
19
20#[derive(Debug, Clone, Copy)]
22pub struct StdHttpTransport;
23
24pub fn require_secure_remote_endpoint(endpoint: &str) -> ControlPlaneResult<()> {
26 let target = SharedHttpTarget::parse(endpoint, "/")
27 .map_err(|error| ControlPlaneError::Rejected(format!("invalid endpoint: {error}")))?;
28 if target.scheme() == SharedHttpScheme::Https
29 || matches!(target.host(), "127.0.0.1" | "::1" | "localhost")
30 {
31 return Ok(());
32 }
33 Err(ControlPlaneError::Rejected(
34 "remote control-plane endpoint requires HTTPS".to_string(),
35 ))
36}
37
38#[derive(Clone)]
40pub struct SecretString(Zeroizing<String>);
41
42impl SecretString {
43 pub fn new(value: impl Into<String>) -> Self {
45 Self(Zeroizing::new(value.into()))
46 }
47
48 pub fn from_zeroizing(value: Zeroizing<String>) -> Self {
50 Self(value)
51 }
52
53 fn expose(&self) -> &str {
54 self.0.as_str()
55 }
56}
57
58impl std::fmt::Debug for SecretString {
59 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 formatter.write_str("SecretString(REDACTED)")
61 }
62}
63
64#[derive(Clone)]
66pub struct BearerHttpTransport {
67 bearer_token: SecretString,
68 max_response_bytes: usize,
69}
70
71impl BearerHttpTransport {
72 pub fn from_secret(bearer_token: SecretString) -> Self {
74 Self {
75 bearer_token,
76 max_response_bytes: DEFAULT_MAX_HTTP_RESPONSE_BYTES,
77 }
78 }
79
80 pub fn with_max_response_bytes(mut self, max_response_bytes: usize) -> Self {
82 self.max_response_bytes = max_response_bytes.max(1);
83 self
84 }
85}
86
87impl std::fmt::Debug for BearerHttpTransport {
88 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 formatter
90 .debug_struct("BearerHttpTransport")
91 .field("bearer_token", &"REDACTED")
92 .field("max_response_bytes", &self.max_response_bytes)
93 .finish()
94 }
95}
96
97impl HttpTransport for StdHttpTransport {
98 fn send_json(
99 &self,
100 base_url: &str,
101 request: HttpControlPlaneRequest,
102 ) -> ControlPlaneResult<HttpControlPlaneResponse> {
103 send_http_json(
104 base_url,
105 request,
106 None,
107 DEFAULT_MAX_HTTP_RESPONSE_BYTES,
108 None,
109 None,
110 )
111 }
112
113 fn send_json_traced(
114 &self,
115 base_url: &str,
116 request: HttpControlPlaneRequest,
117 trace: Option<&TraceContext>,
118 ) -> ControlPlaneResult<HttpControlPlaneResponse> {
119 send_http_json(
120 base_url,
121 request,
122 None,
123 DEFAULT_MAX_HTTP_RESPONSE_BYTES,
124 trace,
125 None,
126 )
127 }
128
129 fn send_json_traced_cancellable(
130 &self,
131 base_url: &str,
132 request: HttpControlPlaneRequest,
133 trace: Option<&TraceContext>,
134 cancellation: &CancellationToken,
135 ) -> ControlPlaneResult<HttpControlPlaneResponse> {
136 send_http_json(
137 base_url,
138 request,
139 None,
140 DEFAULT_MAX_HTTP_RESPONSE_BYTES,
141 trace,
142 Some(cancellation),
143 )
144 }
145}
146
147impl HttpTransport for BearerHttpTransport {
148 fn send_json(
149 &self,
150 base_url: &str,
151 request: HttpControlPlaneRequest,
152 ) -> ControlPlaneResult<HttpControlPlaneResponse> {
153 self.send(base_url, request, None)
154 }
155
156 fn send_json_traced(
157 &self,
158 base_url: &str,
159 request: HttpControlPlaneRequest,
160 trace: Option<&TraceContext>,
161 ) -> ControlPlaneResult<HttpControlPlaneResponse> {
162 self.send(base_url, request, trace)
163 }
164
165 fn send_json_traced_cancellable(
166 &self,
167 base_url: &str,
168 request: HttpControlPlaneRequest,
169 trace: Option<&TraceContext>,
170 cancellation: &CancellationToken,
171 ) -> ControlPlaneResult<HttpControlPlaneResponse> {
172 self.send_cancellable(base_url, request, trace, Some(cancellation))
173 }
174}
175
176impl BearerHttpTransport {
177 fn send(
178 &self,
179 base_url: &str,
180 request: HttpControlPlaneRequest,
181 trace: Option<&TraceContext>,
182 ) -> ControlPlaneResult<HttpControlPlaneResponse> {
183 self.send_cancellable(base_url, request, trace, None)
184 }
185
186 fn send_cancellable(
187 &self,
188 base_url: &str,
189 request: HttpControlPlaneRequest,
190 trace: Option<&TraceContext>,
191 cancellation: Option<&CancellationToken>,
192 ) -> ControlPlaneResult<HttpControlPlaneResponse> {
193 send_http_json(
194 base_url,
195 request,
196 Some(self.bearer_token.expose()),
197 self.max_response_bytes,
198 trace,
199 cancellation,
200 )
201 }
202}
203
204fn send_http_json(
205 base_url: &str,
206 request: HttpControlPlaneRequest,
207 bearer_token: Option<&str>,
208 max_response_bytes: usize,
209 trace: Option<&TraceContext>,
210 cancellation: Option<&CancellationToken>,
211) -> ControlPlaneResult<HttpControlPlaneResponse> {
212 validate_http_request(&request, bearer_token)?;
213 let target = SharedHttpTarget::parse(base_url, &request.path).map_err(map_transport_error)?;
214 let transport_request = build_request(&request, bearer_token, trace)?;
215 let response = send(
216 &target,
217 &transport_request,
218 HttpClientConfig {
219 timeout_ms: request.timeout_ms.max(1),
220 max_request_bytes: DEFAULT_MAX_HTTP_RESPONSE_BYTES,
221 max_response_bytes,
222 max_header_bytes: MAX_HTTP_HEADER_BYTES,
223 },
224 cancellation,
225 )
226 .map_err(map_transport_error)?;
227 Ok(HttpControlPlaneResponse {
228 status_code: response.status_code,
229 body: response.body,
230 })
231}
232
233fn build_request(
234 request: &HttpControlPlaneRequest,
235 bearer_token: Option<&str>,
236 trace: Option<&TraceContext>,
237) -> ControlPlaneResult<HttpRequest> {
238 let mut transport = HttpRequest::new(request.method.clone(), request.body.clone())
239 .map_err(map_transport_error)?
240 .with_header(
241 HttpHeader::new("Content-Type", "application/json").map_err(map_transport_error)?,
242 )
243 .with_header(HttpHeader::new("Accept", "application/json").map_err(map_transport_error)?)
244 .with_header(HttpHeader::new("Accept-Encoding", "gzip").map_err(map_transport_error)?);
245 if let Some(token) = bearer_token {
246 transport = transport.with_header(
247 HttpHeader::sensitive("Authorization", format!("Bearer {token}"))
248 .map_err(map_transport_error)?,
249 );
250 }
251 for header in trace_http_headers(trace)? {
252 transport = transport.with_header(header);
253 }
254 Ok(transport)
255}
256
257fn validate_http_request(
258 request: &HttpControlPlaneRequest,
259 bearer_token: Option<&str>,
260) -> ControlPlaneResult<()> {
261 if request.method.is_empty() || !request.method.bytes().all(|byte| byte.is_ascii_uppercase()) {
262 return Err(ControlPlaneError::Transport(
263 "invalid HTTP method".to_string(),
264 ));
265 }
266 if bearer_token.is_some_and(|token| {
267 token.is_empty() || token.chars().any(|character| character.is_control())
268 }) {
269 return Err(ControlPlaneError::Transport(
270 "invalid bearer credential".to_string(),
271 ));
272 }
273 Ok(())
274}
275
276fn trace_http_headers(trace: Option<&TraceContext>) -> ControlPlaneResult<Vec<HttpHeader>> {
277 let Some(trace) = trace else {
278 return Ok(Vec::new());
279 };
280 let fields = [
281 ("X-AppCore-Trace-Id", Some(trace.trace_id.as_str())),
282 ("X-AppCore-Span-Id", Some(trace.span_id.as_str())),
283 ("X-AppCore-Parent-Span-Id", trace.parent_span_id.as_deref()),
284 (
285 "X-AppCore-Origin-Core-Id",
286 Some(trace.originating_core_id.as_str()),
287 ),
288 (
289 "X-AppCore-Current-Core-Id",
290 Some(trace.current_core_id.as_str()),
291 ),
292 ("X-AppCore-Tenant-Id", Some(trace.tenant_id.as_str())),
293 ("X-AppCore-Command-Id", trace.command_id.as_deref()),
294 ];
295 fields
296 .into_iter()
297 .filter_map(|(name, value)| value.map(|value| (name, value)))
298 .map(|(name, value)| HttpHeader::new(name, value).map_err(map_transport_error))
299 .collect()
300}
301
302#[cfg(test)]
303pub(crate) fn control_plane_trace_headers(
304 trace: Option<&TraceContext>,
305) -> ControlPlaneResult<String> {
306 let mut encoded = String::new();
307 for header in trace_http_headers(trace)? {
308 encoded.push_str(header.name());
309 encoded.push_str(": ");
310 encoded.push_str(header.value());
311 encoded.push_str("\r\n");
312 }
313 Ok(encoded)
314}
315
316fn map_transport_error(error: TransportError) -> ControlPlaneError {
317 match error {
318 TransportError::Timeout => ControlPlaneError::Timeout,
319 TransportError::InvalidResponse(reason) => ControlPlaneError::InvalidResponse(reason),
320 TransportError::TruncatedResponse => {
321 ControlPlaneError::InvalidResponse("truncated HTTP response".to_string())
322 }
323 TransportError::ResponseTooLarge { .. } => {
324 ControlPlaneError::InvalidResponse("HTTP response exceeds configured limit".to_string())
325 }
326 other => ControlPlaneError::Transport(other.to_string()),
327 }
328}
329
330#[cfg(test)]
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub(crate) enum HttpScheme {
333 Http,
334 Https,
335}
336
337#[cfg(test)]
338pub(crate) struct HttpTarget {
339 pub(crate) scheme: HttpScheme,
340 pub(crate) port: u16,
341 pub(crate) path: String,
342}
343
344#[cfg(test)]
345impl HttpTarget {
346 pub(crate) fn parse(base_url: &str, path: &str) -> ControlPlaneResult<Self> {
347 let target = SharedHttpTarget::parse(base_url, path).map_err(map_transport_error)?;
348 Ok(Self {
349 scheme: match target.scheme() {
350 SharedHttpScheme::Http => HttpScheme::Http,
351 SharedHttpScheme::Https => HttpScheme::Https,
352 },
353 port: target.port(),
354 path: target.path().to_string(),
355 })
356 }
357}
358
359#[cfg(test)]
360pub(crate) fn parse_http_response(
361 raw: &[u8],
362 max_response_bytes: usize,
363) -> ControlPlaneResult<HttpControlPlaneResponse> {
364 let response = parse_response(raw, MAX_HTTP_HEADER_BYTES, max_response_bytes)
365 .map_err(map_transport_error)?;
366 Ok(HttpControlPlaneResponse {
367 status_code: response.status_code,
368 body: response.body,
369 })
370}