Skip to main content

appcore_control_plane/
transport.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: transport.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/22 15:41:18 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/24 16:07:49 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11use 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/// Shared bounded HTTP transport for unauthenticated deployment-local calls.
21#[derive(Debug, Clone, Copy)]
22pub struct StdHttpTransport;
23
24/// Rejects plain HTTP for non-loopback control-plane endpoints.
25pub 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/// Redacted bearer material that is zeroized when released.
39#[derive(Clone)]
40pub struct SecretString(Zeroizing<String>);
41
42impl SecretString {
43    /// Wraps an owned secret value.
44    pub fn new(value: impl Into<String>) -> Self {
45        Self(Zeroizing::new(value.into()))
46    }
47
48    /// Adopts an already-zeroizing value without a plain-text copy.
49    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/// HTTP transport that authenticates requests with a zeroizing bearer token.
65#[derive(Clone)]
66pub struct BearerHttpTransport {
67    bearer_token: SecretString,
68    max_response_bytes: usize,
69}
70
71impl BearerHttpTransport {
72    /// Creates a bearer transport without exposing or copying its secret.
73    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    /// Sets the maximum accepted response body size in bytes.
81    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}