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}