Skip to main content

microvms_app/session/
http.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The HTTP seam: one request/response shape, two backends.
3//!
4//! # Why a trait rather than reqwest directly
5//!
6//! Everything worth testing about this client sits *above* HTTP — whether both proxy
7//! headers went out, whether a mint happened inside the retry path, whether a
8//! reconnect resumed at the right byte. reqwest opens real sockets, so none of those
9//! are reachable from a test that does not stand up a server, and the two that matter
10//! most are only reachable by inspecting a request that was already sent.
11//!
12//! So [`HttpBackend`] is the seam. Production is `ReqwestBackend` (in `microvms-edges`, and
13//! at `microvms_core::session::ReqwestBackend`); a test supplies a
14//! recorder that keeps every request head, or a backend that writes bytes at a
15//! simulated network. The daemon made the mirror-image choice with
16//! `axum::serve::Listener`, and for the same reason: the simulator stays out of the
17//! shipping artifact.
18//!
19//! # Streaming is a separate method
20//!
21//! [`HttpBackend::send`] collects the whole body, which for an SSE attach means it
22//! returns once the command is over — and every property worth checking about a stream
23//! is only observable partway through. [`HttpBackend::open_stream`] hands back a chunk
24//! source instead.
25
26use std::collections::HashMap;
27use std::fmt;
28use std::sync::Arc;
29use std::time::Duration;
30
31use futures_util::future::BoxFuture;
32
33use crate::error::{Error, WireKind};
34
35/// One request, already fully addressed: absolute URL, every header, whole body.
36///
37/// Owned rather than borrowed because a backend may need to move it into a spawned
38/// task, and a recorder needs to keep it after the call returns.
39#[derive(Clone)]
40pub struct HttpRequest {
41    pub method: &'static str,
42    /// Path plus query string. Not a full URL: the base is the backend's, so a
43    /// rebound endpoint changes one field rather than every call site.
44    pub path: String,
45    pub headers: Vec<(String, String)>,
46    pub body: Vec<u8>,
47    /// How long the whole exchange may take. `None` means the backend's default.
48    pub timeout: Option<Duration>,
49}
50
51impl HttpRequest {
52    pub fn new(method: &'static str, path: impl Into<String>) -> Self {
53        Self {
54            method,
55            path: path.into(),
56            headers: Vec::new(),
57            body: Vec::new(),
58            timeout: None,
59        }
60    }
61
62    /// One header's value, matched case-insensitively.
63    pub fn header(&self, name: &str) -> Option<&str> {
64        self.headers
65            .iter()
66            .find(|(key, _)| key.eq_ignore_ascii_case(name))
67            .map(|(_, value)| value.as_str())
68    }
69}
70
71/// Names the headers, never their values, and sizes the body rather than printing it.
72///
73/// Every request on this seam carries `authorization: Bearer <agent token>`, and a
74/// streaming attach also carries the minted `X-aws-proxy-auth` — so a derived `Debug` puts
75/// two credentials in any log line, error chain, or test-failure message that formats a
76/// request. Redacting *all* header values rather than allowlisting the two known ones,
77/// because an allowlist is a list somebody has to remember to extend the next time the
78/// platform adds a header, and the platform's stated reason for a token *map* is that it
79/// may need more than one.
80///
81/// The body is sized rather than shown for the same reason: an upload body is file
82/// contents, a stdin body is base64 of whatever the caller is feeding a child, and neither
83/// belongs in a log.
84impl fmt::Debug for HttpRequest {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        let names: Vec<&str> = self.headers.iter().map(|(name, _)| name.as_str()).collect();
87        f.debug_struct("HttpRequest")
88            .field("method", &self.method)
89            .field("path", &self.path)
90            .field("headers", &names)
91            .field("body_len", &self.body.len())
92            .field("timeout", &self.timeout)
93            .finish()
94    }
95}
96
97/// One response, body already collected.
98#[derive(Clone, Debug)]
99pub struct HttpResponse {
100    pub status: u16,
101    pub headers: HashMap<String, String>,
102    pub body: Vec<u8>,
103}
104
105impl HttpResponse {
106    /// The typed error for this response, or `Ok` for a 2xx.
107    ///
108    /// Every non-2xx goes through here, so the status-to-[`WireKind`] table is applied
109    /// in exactly one place. A status the daemon never chooses becomes a
110    /// [`WireKind::ServerError`]-free plain protocol failure rather than being mapped
111    /// to the nearest neighbour — see [`crate::error::WireKind::from_status`] on why
112    /// there is no generic 4xx fallback.
113    pub fn error_for_status(&self, method: &str, path: &str) -> Result<(), Error> {
114        if (200..300).contains(&self.status) {
115            return Ok(());
116        }
117        // Capped at 512 bytes, as the Python does: a detail string is for a human
118        // reading a log, and a 256 MB error body in a message is its own incident.
119        let detail = String::from_utf8_lossy(&self.body[..self.body.len().min(512)])
120            .trim()
121            .to_string();
122        let message = if detail.is_empty() {
123            format!("{method} {path} -> {}", self.status)
124        } else {
125            format!("{method} {path} -> {}: {detail}", self.status)
126        };
127        match WireKind::from_status(self.status) {
128            Some(wire) => Err(Error::wire(wire, message)),
129            // A status outside the daemon's vocabulary. `Protocol` rather than
130            // `Retryable`, because nothing says a retry would land differently, and
131            // rather than a specific wire kind, because inventing one would be this
132            // client claiming to know a meaning the daemon never assigned.
133            None => Err(Error::new(crate::error::ErrorKind::Protocol, message)),
134        }
135    }
136}
137
138/// A body arriving in pieces.
139///
140/// One method, because that is all a cursor-driven stream needs: `Ok(None)` is the end
141/// of the body, however it ended. Which *way* it ended is not this trait's business —
142/// the protocol answers that with the presence or absence of a terminal `exit` event,
143/// which is exactly why the transport is framed.
144pub trait ChunkSource: Send {
145    fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>>;
146}
147
148/// A streaming response: the head, and the body still to arrive.
149///
150/// Named rather than spelled inline at [`HttpBackend::open_stream`], because the two
151/// halves are one thing — the status has to be readable before the first body byte, so a
152/// backend cannot hand back one without the other.
153pub type OpenStream = (HttpResponse, Box<dyn ChunkSource>);
154
155/// The seam. Production and simulated transports implement this.
156pub trait HttpBackend: Send + Sync {
157    /// Sends one request and collects the whole body, whatever the status.
158    ///
159    /// Deliberately does not fail on a status: a conformance suite asserts on 401 and
160    /// 409 as expected outcomes, and a client that could only reach them through an
161    /// error would be a client that cannot test the protocol.
162    fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>>;
163
164    /// Opens a streaming response, reading the status before any body byte.
165    ///
166    /// Status first so a 404 on an unknown exec id surfaces as `NotFound` rather than
167    /// as an empty stream. `idle_timeout` bounds *silence* rather than duration: an SSE
168    /// body is idle by design between chunks, so the useful bound is how long a gap may
169    /// last. Without one, a half-open connection — the failure a NAT or proxy timeout
170    /// produces, where no FIN ever arrives — hangs forever and the reconnect logic
171    /// never runs.
172    fn open_stream(
173        &self,
174        request: HttpRequest,
175        idle_timeout: Duration,
176    ) -> BoxFuture<'_, Result<OpenStream, Error>>;
177}
178
179/// A backend behind an `Arc`, which is what a [`crate::session::Session`] holds.
180pub type SharedBackend = Arc<dyn HttpBackend>;
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::error::ErrorKind;
186
187    /// Each status the daemon chooses becomes its own variant, and an unmapped 4xx
188    /// becomes none of them.
189    #[test]
190    fn a_response_status_becomes_the_wire_kind_the_daemon_meant() {
191        let response = |status: u16| HttpResponse {
192            status,
193            headers: HashMap::new(),
194            body: b"{\"error\":\"unknown_exec\",\"detail\":\"e1\"}".to_vec(),
195        };
196        assert!(response(200).error_for_status("GET", "/v1/health").is_ok());
197
198        let err = response(404)
199            .error_for_status("GET", "/v1/exec/e1")
200            .expect_err("404 is an error");
201        assert_eq!(err.wire_kind(), Some(WireKind::NotFound));
202        assert!(
203            err.to_string().contains("unknown_exec"),
204            "the daemon's detail must survive into the message: {err}"
205        );
206
207        let err = response(429)
208            .error_for_status("GET", "/v1/health")
209            .expect_err("an unmapped status is still an error");
210        assert_eq!(err.kind(), ErrorKind::Protocol);
211        assert_eq!(
212            err.wire_kind(),
213            None,
214            "a status the daemon never chooses must not be given an invented meaning"
215        );
216    }
217
218    /// A body long enough to be its own problem is truncated in the message.
219    #[test]
220    fn an_enormous_error_body_is_capped_in_the_message() {
221        let response = HttpResponse {
222            status: 500,
223            headers: HashMap::new(),
224            body: vec![b'x'; 100_000],
225        };
226        let err = response
227            .error_for_status("PUT", "/v1/fs/file")
228            .expect_err("500 is an error");
229        assert!(err.to_string().len() < 700, "{}", err.to_string().len());
230    }
231
232    /// Header lookup on a request is case-insensitive, since a caller may spell a
233    /// header either way and the proxy compares insensitively.
234    #[test]
235    fn a_request_header_is_found_whatever_case_it_was_set_in() {
236        let mut request = HttpRequest::new("GET", "/v1/health");
237        request
238            .headers
239            .push(("X-Aws-Proxy-Port".into(), "9000".into()));
240        assert_eq!(request.header("x-aws-proxy-port"), Some("9000"));
241        assert_eq!(request.header("authorization"), None);
242    }
243
244    /// A request's `Debug` names its headers and never their values, so neither the agent
245    /// token nor the minted proxy token reaches a log line or a test-failure message.
246    ///
247    /// **Falsification** — restore `#[derive(Debug)]` on [`HttpRequest`] and both value
248    /// assertions fail; drop the body from the redaction and the last one fails.
249    #[test]
250    fn a_request_debug_prints_header_names_and_never_their_values() {
251        let mut request = HttpRequest::new("POST", "/v1/exec/e1/stdin");
252        request.headers.push((
253            "authorization".into(),
254            "Bearer super-secret-agent-token".into(),
255        ));
256        request
257            .headers
258            .push(("X-aws-proxy-auth".into(), "eyJhbGciOi-secret-jwe".into()));
259        request.body = b"secret-body-bytes".to_vec();
260        request.timeout = Some(Duration::from_secs(7));
261
262        let rendered = format!("{request:?}");
263        assert!(rendered.contains("authorization"), "{rendered}");
264        assert!(rendered.contains("X-aws-proxy-auth"), "{rendered}");
265        assert!(rendered.contains("/v1/exec/e1/stdin"), "{rendered}");
266        assert!(rendered.contains("POST"), "{rendered}");
267        assert!(rendered.contains('7'), "the timeout survives: {rendered}");
268        assert!(
269            rendered.contains("body_len"),
270            "the body's size is diagnostic: {rendered}"
271        );
272        assert!(
273            !rendered.contains("super-secret-agent-token"),
274            "the agent token reached a Debug string: {rendered}"
275        );
276        assert!(
277            !rendered.contains("eyJhbGciOi-secret-jwe"),
278            "the minted proxy token reached a Debug string: {rendered}"
279        );
280        assert!(
281            !rendered.contains("secret-body-bytes"),
282            "the body reached a Debug string: {rendered}"
283        );
284    }
285}