1use std::collections::HashMap;
26use std::fmt;
27use std::sync::Arc;
28use std::time::Duration;
29
30use futures_util::future::BoxFuture;
31
32use crate::error::{Error, WireKind};
33
34#[derive(Clone)]
39pub struct HttpRequest {
40 pub method: &'static str,
41 pub path: String,
44 pub headers: Vec<(String, String)>,
45 pub body: Vec<u8>,
46 pub timeout: Option<Duration>,
48}
49
50impl HttpRequest {
51 pub fn new(method: &'static str, path: impl Into<String>) -> Self {
52 Self {
53 method,
54 path: path.into(),
55 headers: Vec::new(),
56 body: Vec::new(),
57 timeout: None,
58 }
59 }
60
61 pub fn header(&self, name: &str) -> Option<&str> {
63 self.headers
64 .iter()
65 .find(|(key, _)| key.eq_ignore_ascii_case(name))
66 .map(|(_, value)| value.as_str())
67 }
68}
69
70impl fmt::Debug for HttpRequest {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 let names: Vec<&str> = self.headers.iter().map(|(name, _)| name.as_str()).collect();
86 f.debug_struct("HttpRequest")
87 .field("method", &self.method)
88 .field("path", &self.path)
89 .field("headers", &names)
90 .field("body_len", &self.body.len())
91 .field("timeout", &self.timeout)
92 .finish()
93 }
94}
95
96#[derive(Clone, Debug)]
98pub struct HttpResponse {
99 pub status: u16,
100 pub headers: HashMap<String, String>,
101 pub body: Vec<u8>,
102}
103
104impl HttpResponse {
105 pub fn error_for_status(&self, method: &str, path: &str) -> Result<(), Error> {
113 if (200..300).contains(&self.status) {
114 return Ok(());
115 }
116 let detail = String::from_utf8_lossy(&self.body[..self.body.len().min(512)])
119 .trim()
120 .to_string();
121 let message = if detail.is_empty() {
122 format!("{method} {path} -> {}", self.status)
123 } else {
124 format!("{method} {path} -> {}: {detail}", self.status)
125 };
126 match crate::error::WireKind::from_status(self.status) {
127 Some(wire) => Err(Error::wire(wire, message)),
128 None => Err(Error::new(crate::error::ErrorKind::Protocol, message)),
133 }
134 }
135}
136
137pub trait ChunkSource: Send {
144 fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>>;
145}
146
147pub type OpenStream = (HttpResponse, Box<dyn ChunkSource>);
153
154pub trait HttpBackend: Send + Sync {
156 fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>>;
162
163 fn open_stream(
172 &self,
173 request: HttpRequest,
174 idle_timeout: Duration,
175 ) -> BoxFuture<'_, Result<OpenStream, Error>>;
176}
177
178pub struct ReqwestBackend {
184 client: reqwest::Client,
185 base_url: String,
186 timeout: Duration,
187}
188
189impl ReqwestBackend {
190 pub fn new(base_url: &str, timeout: Duration) -> Result<Self, Error> {
196 let client = reqwest::Client::builder()
197 .timeout(timeout)
198 .build()
199 .map_err(|err| {
200 Error::new(
201 crate::error::ErrorKind::Unexpected,
202 format!("could not build an HTTP client: {err}"),
203 )
204 .with_source(err)
205 })?;
206 Ok(Self {
207 client,
208 base_url: normalize_base_url(base_url),
209 timeout,
210 })
211 }
212
213 fn url(&self, path: &str) -> String {
214 format!("{}{path}", self.base_url)
215 }
216
217 fn build(&self, request: &HttpRequest) -> reqwest::RequestBuilder {
218 let mut builder = self
219 .client
220 .request(
221 reqwest::Method::from_bytes(request.method.as_bytes())
222 .unwrap_or(reqwest::Method::GET),
223 self.url(&request.path),
224 )
225 .timeout(request.timeout.unwrap_or(self.timeout));
226 for (name, value) in &request.headers {
227 builder = builder.header(name, value);
228 }
229 if !request.body.is_empty() {
230 builder = builder.body(request.body.clone());
231 }
232 builder
233 }
234}
235
236fn normalize_base_url(base_url: &str) -> String {
239 let with_scheme = if base_url.starts_with("http://") || base_url.starts_with("https://") {
240 base_url.to_string()
241 } else {
242 format!("https://{base_url}")
243 };
244 with_scheme.trim_end_matches('/').to_string()
245}
246
247fn transport_error(method: &str, path: &str, err: reqwest::Error) -> Error {
249 Error::wire(
250 WireKind::Transport,
251 format!("{method} {path} failed on the wire: {err}"),
252 )
253 .with_source(err)
254}
255
256fn collect_headers(response: &reqwest::Response) -> HashMap<String, String> {
257 response
258 .headers()
259 .iter()
260 .map(|(name, value)| {
261 (
262 name.as_str().to_string(),
263 String::from_utf8_lossy(value.as_bytes()).into_owned(),
264 )
265 })
266 .collect()
267}
268
269impl HttpBackend for ReqwestBackend {
270 fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
271 Box::pin(async move {
272 let response = self
273 .build(&request)
274 .send()
275 .await
276 .map_err(|err| transport_error(request.method, &request.path, err))?;
277 let status = response.status().as_u16();
278 let headers = collect_headers(&response);
279 let body = response
280 .bytes()
281 .await
282 .map_err(|err| transport_error(request.method, &request.path, err))?
283 .to_vec();
284 Ok(HttpResponse {
285 status,
286 headers,
287 body,
288 })
289 })
290 }
291
292 fn open_stream(
293 &self,
294 request: HttpRequest,
295 idle_timeout: Duration,
296 ) -> BoxFuture<'_, Result<OpenStream, Error>> {
297 Box::pin(async move {
298 let response = self
299 .build(&request)
300 .timeout(Duration::MAX)
304 .send()
305 .await
306 .map_err(|err| transport_error(request.method, &request.path, err))?;
307 let status = response.status().as_u16();
308 let headers = collect_headers(&response);
309
310 if !(200..300).contains(&status) {
313 let body = response
314 .bytes()
315 .await
316 .map_err(|err| transport_error(request.method, &request.path, err))?
317 .to_vec();
318 return Ok((
319 HttpResponse {
320 status,
321 headers,
322 body,
323 },
324 Box::new(EmptyChunks) as Box<dyn ChunkSource>,
325 ));
326 }
327
328 let head = HttpResponse {
329 status,
330 headers,
331 body: Vec::new(),
332 };
333 let chunks = ReqwestChunks {
334 response: Some(response),
335 idle_timeout,
336 method: request.method,
337 path: request.path,
338 };
339 Ok((head, Box::new(chunks) as Box<dyn ChunkSource>))
340 })
341 }
342}
343
344struct EmptyChunks;
346
347impl ChunkSource for EmptyChunks {
348 fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
349 Box::pin(async { Ok(None) })
350 }
351}
352
353struct ReqwestChunks {
354 response: Option<reqwest::Response>,
355 idle_timeout: Duration,
356 method: &'static str,
357 path: String,
358}
359
360impl ChunkSource for ReqwestChunks {
361 fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
362 Box::pin(async move {
363 let Some(response) = self.response.as_mut() else {
364 return Ok(None);
365 };
366 match tokio::time::timeout(self.idle_timeout, response.chunk()).await {
367 Ok(Ok(Some(bytes))) => Ok(Some(bytes.to_vec())),
368 Ok(Ok(None)) => {
369 self.response = None;
370 Ok(None)
371 }
372 Ok(Err(err)) => {
373 self.response = None;
374 Err(transport_error(self.method, &self.path, err))
375 }
376 Err(_) => {
377 self.response = None;
378 Err(Error::wire(
381 WireKind::Transport,
382 format!(
383 "{} {} went silent for {}s, longer than the keepalive \
384 interval, so the connection is treated as dead",
385 self.method,
386 self.path,
387 self.idle_timeout.as_secs()
388 ),
389 ))
390 }
391 }
392 })
393 }
394}
395
396impl fmt::Debug for ReqwestBackend {
397 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398 f.debug_struct("ReqwestBackend")
399 .field("base_url", &self.base_url)
400 .field("timeout", &self.timeout)
401 .finish()
402 }
403}
404
405pub type SharedBackend = Arc<dyn HttpBackend>;
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::error::ErrorKind;
412
413 #[test]
416 fn a_bare_endpoint_host_is_read_as_https() {
417 assert_eq!(
418 normalize_base_url("vm-abc.microvms.aws"),
419 "https://vm-abc.microvms.aws"
420 );
421 assert_eq!(
422 normalize_base_url("http://127.0.0.1:9000"),
423 "http://127.0.0.1:9000",
424 "an explicit scheme is honoured, so a local daemon is still reachable"
425 );
426 assert_eq!(
427 normalize_base_url("https://host/"),
428 "https://host",
429 "a trailing slash would produce a double one on every path"
430 );
431 }
432
433 #[test]
436 fn a_response_status_becomes_the_wire_kind_the_daemon_meant() {
437 let response = |status: u16| HttpResponse {
438 status,
439 headers: HashMap::new(),
440 body: b"{\"error\":\"unknown_exec\",\"detail\":\"e1\"}".to_vec(),
441 };
442 assert!(response(200).error_for_status("GET", "/v1/health").is_ok());
443
444 let err = response(404)
445 .error_for_status("GET", "/v1/exec/e1")
446 .expect_err("404 is an error");
447 assert_eq!(err.wire_kind(), Some(WireKind::NotFound));
448 assert!(
449 err.to_string().contains("unknown_exec"),
450 "the daemon's detail must survive into the message: {err}"
451 );
452
453 let err = response(429)
454 .error_for_status("GET", "/v1/health")
455 .expect_err("an unmapped status is still an error");
456 assert_eq!(err.kind(), ErrorKind::Protocol);
457 assert_eq!(
458 err.wire_kind(),
459 None,
460 "a status the daemon never chooses must not be given an invented meaning"
461 );
462 }
463
464 #[test]
466 fn an_enormous_error_body_is_capped_in_the_message() {
467 let response = HttpResponse {
468 status: 500,
469 headers: HashMap::new(),
470 body: vec![b'x'; 100_000],
471 };
472 let err = response
473 .error_for_status("PUT", "/v1/fs/file")
474 .expect_err("500 is an error");
475 assert!(err.to_string().len() < 700, "{}", err.to_string().len());
476 }
477
478 #[test]
481 fn a_request_header_is_found_whatever_case_it_was_set_in() {
482 let mut request = HttpRequest::new("GET", "/v1/health");
483 request
484 .headers
485 .push(("X-Aws-Proxy-Port".into(), "9000".into()));
486 assert_eq!(request.header("x-aws-proxy-port"), Some("9000"));
487 assert_eq!(request.header("authorization"), None);
488 }
489
490 #[test]
496 fn a_request_debug_prints_header_names_and_never_their_values() {
497 let mut request = HttpRequest::new("POST", "/v1/exec/e1/stdin");
498 request.headers.push((
499 "authorization".into(),
500 "Bearer super-secret-agent-token".into(),
501 ));
502 request
503 .headers
504 .push(("X-aws-proxy-auth".into(), "eyJhbGciOi-secret-jwe".into()));
505 request.body = b"secret-body-bytes".to_vec();
506 request.timeout = Some(Duration::from_secs(7));
507
508 let rendered = format!("{request:?}");
509 assert!(rendered.contains("authorization"), "{rendered}");
510 assert!(rendered.contains("X-aws-proxy-auth"), "{rendered}");
511 assert!(rendered.contains("/v1/exec/e1/stdin"), "{rendered}");
512 assert!(rendered.contains("POST"), "{rendered}");
513 assert!(rendered.contains('7'), "the timeout survives: {rendered}");
514 assert!(
515 rendered.contains("body_len"),
516 "the body's size is diagnostic: {rendered}"
517 );
518 assert!(
519 !rendered.contains("super-secret-agent-token"),
520 "the agent token reached a Debug string: {rendered}"
521 );
522 assert!(
523 !rendered.contains("eyJhbGciOi-secret-jwe"),
524 "the minted proxy token reached a Debug string: {rendered}"
525 );
526 assert!(
527 !rendered.contains("secret-body-bytes"),
528 "the body reached a Debug string: {rendered}"
529 );
530 }
531}