Skip to main content

rustev_remote_http/
http.rs

1//! An HTTP/1.1 exchange under one budget, for any remote adapter that POSTs
2//! JSON (spec 009, 3.4 and 3.11): one connection per exchange, TLS required
3//! except for loopback, request bytes tracked, the response read up to a
4//! maximum, redirects never followed, nothing retried.
5//!
6//! Credentials are host-supplied bearer tokens held in [`Secret`], which
7//! never prints its value; nothing here logs.
8
9use std::fmt;
10use std::net::{IpAddr, SocketAddr};
11use std::sync::Arc;
12use std::time::Duration;
13
14use http_body_util::{BodyExt, Full};
15use hyper::body::{Body, Bytes, Frame, SizeHint};
16use hyper::header::{AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, HOST, RETRY_AFTER};
17use hyper::{Request, StatusCode};
18use hyper_util::rt::TokioIo;
19use rustev_contract::remote::{ConnectionObservation, PhaseExpiry, TransportPhase};
20use tokio::io::{AsyncRead, AsyncWrite};
21use tokio::net::TcpStream;
22
23use crate::budget::Budget;
24use crate::phases::{PhaseClock, TransportPhases, phased};
25use crate::pool::{ConnectionPool, KeyMaterial, Lease, Pooled};
26use crate::wire::{IoHooks, SentTracker, SharedHooks, TrackedIo};
27
28/// A credential supplied by the host. Its `Debug` never shows the value,
29/// and no identity or exchange record ever holds it.
30#[derive(Clone, PartialEq, Eq)]
31pub struct Secret(String);
32
33impl Secret {
34    pub fn new(value: impl Into<String>) -> Self {
35        Secret(value.into())
36    }
37
38    /// The value, for the one place that must send it.
39    pub fn expose(&self) -> &str {
40        &self.0
41    }
42}
43
44impl fmt::Debug for Secret {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        f.write_str("Secret(<redacted>)")
47    }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Scheme {
52    Http,
53    Https,
54}
55
56/// Where an adapter sends its exchanges.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Endpoint {
59    pub scheme: Scheme,
60    /// A host name or an IP literal (without brackets).
61    pub host: String,
62    pub port: u16,
63    /// A path prefix without a trailing `/`, possibly empty.
64    pub base_path: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum EndpointError {
69    Syntax(String),
70    /// Plain `http` is allowed only for loopback (3.11).
71    PlaintextNotLoopback,
72    Tls(String),
73}
74
75impl fmt::Display for EndpointError {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        match self {
78            EndpointError::Syntax(s) => write!(f, "endpoint: {s}"),
79            EndpointError::PlaintextNotLoopback => {
80                write!(f, "endpoint: TLS is required except for loopback")
81            }
82            EndpointError::Tls(s) => write!(f, "endpoint TLS configuration: {s}"),
83        }
84    }
85}
86
87impl std::error::Error for EndpointError {}
88
89impl Endpoint {
90    /// Parse `http[s]://host[:port][/base]`. User info, queries and
91    /// fragments are refused.
92    pub fn parse(url: &str) -> Result<Endpoint, EndpointError> {
93        let bad = |why: &str| EndpointError::Syntax(why.to_string());
94        let (scheme, rest) = if let Some(r) = url.strip_prefix("https://") {
95            (Scheme::Https, r)
96        } else if let Some(r) = url.strip_prefix("http://") {
97            (Scheme::Http, r)
98        } else {
99            return Err(bad("the scheme must be http or https"));
100        };
101        if rest.contains(['@', '?', '#']) {
102            return Err(bad("user info, query and fragment are not allowed"));
103        }
104        let (authority, path) = match rest.find('/') {
105            Some(i) => (&rest[..i], &rest[i..]),
106            None => (rest, ""),
107        };
108        let default_port = match scheme {
109            Scheme::Http => 80,
110            Scheme::Https => 443,
111        };
112        let (host, port) = if let Some(v6) = authority.strip_prefix('[') {
113            let end = v6.find(']').ok_or_else(|| bad("unclosed IPv6 literal"))?;
114            let host = &v6[..end];
115            let port = match &v6[end + 1..] {
116                "" => default_port,
117                p => p
118                    .strip_prefix(':')
119                    .and_then(|p| p.parse().ok())
120                    .ok_or_else(|| bad("bad port"))?,
121            };
122            (host.to_string(), port)
123        } else {
124            match authority.rsplit_once(':') {
125                Some((h, p)) => (h.to_string(), p.parse().map_err(|_| bad("bad port"))?),
126                None => (authority.to_string(), default_port),
127            }
128        };
129        if host.is_empty() {
130            return Err(bad("empty host"));
131        }
132        Ok(Endpoint {
133            scheme,
134            host,
135            port,
136            base_path: path.trim_end_matches('/').to_string(),
137        })
138    }
139
140    /// A loopback IP literal, or `localhost` (connected only through
141    /// loopback addresses).
142    pub fn is_loopback(&self) -> bool {
143        self.host.eq_ignore_ascii_case("localhost")
144            || self.host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
145    }
146
147    fn authority(&self) -> String {
148        if self.host.contains(':') {
149            format!("[{}]:{}", self.host, self.port)
150        } else {
151            format!("{}:{}", self.host, self.port)
152        }
153    }
154}
155
156/// What came back.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct HttpReply {
159    pub status: u16,
160    /// The `Retry-After` value, verbatim; recorded, never slept on.
161    pub retry_after: Option<String>,
162    pub body: Vec<u8>,
163}
164
165/// Why no complete reply came back.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum TransportError {
168    /// Resolution, connect or handshake failed; no request byte left.
169    Unsent(String),
170    /// The connection failed after request bytes may have left.
171    Interrupted(String),
172    /// The response body exceeds the maximum; reading stopped there.
173    TooLarge { limit: usize },
174    /// The budget, or a shorter transport limit, ran out.
175    Expired { sent: bool },
176    /// A transport phase's allowance ran out under an enabled phase policy
177    /// (spec 020, 3.4); the expiry names the phase and its code.
178    PhaseExpired(PhaseExpiry),
179}
180
181/// The default TLS client configuration: the webpki roots, `ring`.
182pub fn default_tls() -> Result<Arc<rustls::ClientConfig>, EndpointError> {
183    let mut roots = rustls::RootCertStore::empty();
184    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
185    let config = rustls::ClientConfig::builder_with_provider(Arc::new(
186        rustls::crypto::ring::default_provider(),
187    ))
188    .with_safe_default_protocol_versions()
189    .map_err(|e| EndpointError::Tls(e.to_string()))?
190    .with_root_certificates(roots)
191    .with_no_client_auth();
192    Ok(Arc::new(config))
193}
194
195/// POSTs JSON documents to one endpoint.
196#[derive(Clone)]
197pub struct HttpTransport {
198    endpoint: Endpoint,
199    tls: Option<tokio_rustls::TlsConnector>,
200    bearer: Option<Secret>,
201    transport_timeout: Option<Duration>,
202    phases: TransportPhases,
203    pool: Option<PoolUse>,
204}
205
206/// A pooled HTTP/1.1 connection (spec 020, 3.5): its request sender and the
207/// hooks its stream reports to, swapped for each exchange that uses it.
208pub struct HttpConn {
209    sender: hyper::client::conn::http1::SendRequest<SignalBody>,
210    hooks: SharedHooks,
211}
212
213impl Pooled for HttpConn {
214    fn is_closed(&self) -> bool {
215        self.sender.is_closed()
216    }
217}
218
219/// The connection pool of the HTTP binding.
220pub type HttpPool = ConnectionPool<HttpConn>;
221
222#[derive(Clone)]
223struct PoolUse {
224    pool: Arc<HttpPool>,
225    material: KeyMaterial,
226}
227
228impl fmt::Debug for HttpTransport {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        f.debug_struct("HttpTransport")
231            .field("endpoint", &self.endpoint)
232            .field("tls", &self.tls.is_some())
233            .field("bearer", &self.bearer)
234            .field("transport_timeout", &self.transport_timeout)
235            .field("phases", &self.phases)
236            .field("pooled", &self.pool.is_some())
237            .finish()
238    }
239}
240
241impl HttpTransport {
242    /// Refuses plain `http` to anything but loopback. `tls` overrides the
243    /// default configuration for `https` (a private root, for example).
244    pub fn new(
245        endpoint: Endpoint,
246        tls: Option<Arc<rustls::ClientConfig>>,
247        bearer: Option<Secret>,
248        transport_timeout: Option<Duration>,
249    ) -> Result<Self, EndpointError> {
250        let tls = match endpoint.scheme {
251            Scheme::Http if !endpoint.is_loopback() => {
252                return Err(EndpointError::PlaintextNotLoopback);
253            }
254            Scheme::Http => None,
255            Scheme::Https => Some(tokio_rustls::TlsConnector::from(match tls {
256                Some(c) => c,
257                None => default_tls()?,
258            })),
259        };
260        Ok(HttpTransport {
261            endpoint,
262            tls,
263            bearer,
264            transport_timeout,
265            phases: TransportPhases::Disabled,
266            pool: None,
267        })
268    }
269
270    /// Reuse connections from `pool` under the isolation key `material`
271    /// names (spec 020, 3.5), for [`Self::post_observed`]. The per-exchange
272    /// principal digest is filled in per call.
273    pub fn with_pool(mut self, pool: Arc<HttpPool>, material: KeyMaterial) -> Self {
274        self.pool = Some(PoolUse { pool, material });
275        self
276    }
277
278    /// Divide every exchange's budget into phase ceilings (spec 020, 3.4).
279    /// The caller validates the policy first; disabled changes nothing.
280    pub fn with_phases(mut self, phases: TransportPhases) -> Self {
281        self.phases = phases;
282        self
283    }
284
285    pub fn phases(&self) -> &TransportPhases {
286        &self.phases
287    }
288
289    pub fn endpoint(&self) -> &Endpoint {
290        &self.endpoint
291    }
292
293    /// POST `body` to `path` under the endpoint's base path, within the
294    /// budget (and the transport timeout, when shorter). Request bytes are
295    /// tracked by `sent`; an aborted tracker refuses to send.
296    pub async fn post(
297        &self,
298        path: &str,
299        body: Vec<u8>,
300        max_response_bytes: usize,
301        budget: &Budget,
302        sent: &SentTracker,
303    ) -> Result<HttpReply, TransportError> {
304        let deadline = budget.phase_deadline(self.transport_timeout);
305        let clock = self
306            .phases
307            .policy()
308            .map(|p| PhaseClock::start(*p, *budget, TransportPhase::Resolution));
309        let run = async {
310            let exchange = self.exchange(path, body, max_response_bytes, sent, clock.clone());
311            match &clock {
312                Some(c) => phased(c, sent, exchange).await,
313                None => exchange.await,
314            }
315        };
316        match tokio::time::timeout_at(deadline, run).await {
317            Ok(r) => r,
318            Err(_) => Err(TransportError::Expired {
319                sent: sent.may_have_sent(),
320            }),
321        }
322    }
323
324    async fn connect(&self, clock: &Option<PhaseClock>) -> Result<TcpStream, TransportError> {
325        let unsent = |e: std::io::Error| TransportError::Unsent(format!("connect: {e}"));
326        let addrs: Vec<SocketAddr> =
327            tokio::net::lookup_host((self.endpoint.host.as_str(), self.endpoint.port))
328                .await
329                .map_err(|e| TransportError::Unsent(format!("resolve: {e}")))?
330                .filter(|a| self.endpoint.scheme == Scheme::Https || a.ip().is_loopback())
331                .collect();
332        if let Some(c) = clock {
333            c.enter(TransportPhase::Connect);
334        }
335        let mut last = TransportError::Unsent("resolve: no usable address".into());
336        for a in addrs {
337            match TcpStream::connect(a).await {
338                Ok(s) => {
339                    let _ = s.set_nodelay(true);
340                    return Ok(s);
341                }
342                Err(e) => last = unsent(e),
343            }
344        }
345        Err(last)
346    }
347
348    async fn exchange(
349        &self,
350        path: &str,
351        body: Vec<u8>,
352        max: usize,
353        sent: &SentTracker,
354        clock: Option<PhaseClock>,
355    ) -> Result<HttpReply, TransportError> {
356        let tcp = self.connect(&clock).await?;
357        let enter = |p| {
358            if let Some(c) = &clock {
359                c.enter(p);
360            }
361        };
362        match &self.tls {
363            None => {
364                enter(TransportPhase::RequestWrite);
365                let io = TrackedIo::new(tcp, sent.clone()).with_clock(clock.clone());
366                self.http1(io, path, body, max, sent, clock).await
367            }
368            Some(connector) => {
369                let name = rustls::pki_types::ServerName::try_from(self.endpoint.host.clone())
370                    .map_err(|e| TransportError::Unsent(format!("server name: {e}")))?;
371                enter(TransportPhase::SecureHandshake);
372                let tls = connector
373                    .connect(name, tcp)
374                    .await
375                    .map_err(|e| TransportError::Unsent(format!("TLS handshake: {e}")))?;
376                enter(TransportPhase::RequestWrite);
377                let io = TrackedIo::new(tls, sent.clone()).with_clock(clock.clone());
378                self.http1(io, path, body, max, sent, clock).await
379            }
380        }
381    }
382
383    async fn http1<S>(
384        &self,
385        stream: S,
386        path: &str,
387        body: Vec<u8>,
388        max: usize,
389        sent: &SentTracker,
390        clock: Option<PhaseClock>,
391    ) -> Result<HttpReply, TransportError>
392    where
393        S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
394    {
395        let failed = |what: &str, e: &dyn fmt::Display| {
396            let d = format!("{what}: {e}");
397            if sent.may_have_sent() {
398                TransportError::Interrupted(d)
399            } else {
400                TransportError::Unsent(d)
401            }
402        };
403        let (mut sender, conn) =
404            hyper::client::conn::http1::handshake::<_, SignalBody>(TokioIo::new(stream))
405                .await
406                .map_err(|e| failed("http handshake", &e))?;
407        tokio::pin!(conn);
408        let req = self.request(path, body, clock)?;
409        let send = sender.send_request(req);
410        tokio::pin!(send);
411        let resp = tokio::select! {
412            r = &mut send => r,
413            c = &mut conn => match c {
414                Err(e) => return Err(failed("connection", &e)),
415                Ok(()) => (&mut send).await,
416            },
417        }
418        .map_err(|e| failed("request", &e))?;
419        let status = resp.status();
420        let retry_after = resp
421            .headers()
422            .get(RETRY_AFTER)
423            .map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned());
424        let declared = resp
425            .headers()
426            .get(CONTENT_LENGTH)
427            .and_then(|v| v.to_str().ok())
428            .and_then(|v| v.parse::<u64>().ok());
429        if declared.is_some_and(|n| n > max as u64) {
430            return Err(TransportError::TooLarge { limit: max });
431        }
432        let mut body = resp.into_body();
433        let mut buf = Vec::new();
434        let mut conn_done = false;
435        loop {
436            let frame = if conn_done {
437                body.frame().await
438            } else {
439                tokio::select! {
440                    f = body.frame() => f,
441                    c = &mut conn => {
442                        conn_done = true;
443                        if let Err(e) = c {
444                            return Err(failed("connection", &e));
445                        }
446                        continue;
447                    }
448                }
449            };
450            match frame {
451                None => break,
452                Some(Err(e)) => return Err(failed("response body", &e)),
453                Some(Ok(f)) => {
454                    if let Ok(data) = f.into_data() {
455                        if buf.len() + data.len() > max {
456                            return Err(TransportError::TooLarge { limit: max });
457                        }
458                        buf.extend_from_slice(&data);
459                    }
460                }
461            }
462        }
463        Ok(HttpReply {
464            status: status_code(status),
465            retry_after,
466            body: buf,
467        })
468    }
469}
470
471impl HttpTransport {
472    fn request(
473        &self,
474        path: &str,
475        body: Vec<u8>,
476        clock: Option<PhaseClock>,
477    ) -> Result<Request<SignalBody>, TransportError> {
478        let mut req = Request::post(format!("{}{}", self.endpoint.base_path, path))
479            .header(HOST, self.endpoint.authority())
480            .header(CONTENT_TYPE, "application/json")
481            .header(CONTENT_LENGTH, body.len());
482        if let Some(b) = &self.bearer {
483            req = req.header(AUTHORIZATION, format!("Bearer {}", b.expose()));
484        }
485        req.body(SignalBody {
486            inner: Full::new(Bytes::from(body)),
487            clock,
488        })
489        .map_err(|e| TransportError::Unsent(format!("request: {e}")))
490    }
491
492    /// [`Self::post`], on a pooled connection when a pool is configured:
493    /// an idle connection under exactly this exchange's isolation key, or a
494    /// fresh one within the pool's bounds. Also returns what evidence
495    /// states about the connection. A pool with no room is
496    /// `transport_unsent`; a stale connection fails as its bytes say, and
497    /// is never replaced mid-exchange (spec 020, 3.5).
498    pub async fn post_observed(
499        &self,
500        path: &str,
501        body: Vec<u8>,
502        max_response_bytes: usize,
503        budget: &Budget,
504        sent: &SentTracker,
505        principal_digest: Option<String>,
506    ) -> (
507        Result<HttpReply, TransportError>,
508        Option<ConnectionObservation>,
509    ) {
510        let Some(pu) = &self.pool else {
511            return (
512                self.post(path, body, max_response_bytes, budget, sent)
513                    .await,
514                None,
515            );
516        };
517        let deadline = budget.phase_deadline(self.transport_timeout);
518        let mut material = pu.material.clone();
519        material.principal = principal_digest;
520        let key = material.key();
521        let mut lease = match tokio::time::timeout_at(deadline, pu.pool.acquire(&key, budget)).await
522        {
523            Ok(Ok(l)) => l,
524            _ => {
525                return (
526                    Err(TransportError::Unsent(
527                        "the connection pool has no room within the budget".into(),
528                    )),
529                    None,
530                );
531            }
532        };
533        let clock = self
534            .phases
535            .policy()
536            .map(|p| PhaseClock::start(*p, *budget, TransportPhase::Resolution));
537        let run = async {
538            let exchange = self.pooled(
539                &mut lease,
540                path,
541                body,
542                max_response_bytes,
543                sent,
544                clock.clone(),
545            );
546            match &clock {
547                Some(c) => phased(c, sent, exchange).await,
548                None => exchange.await,
549            }
550        };
551        let result = match tokio::time::timeout_at(deadline, run).await {
552            Ok(r) => r,
553            Err(_) => Err(TransportError::Expired {
554                sent: sent.may_have_sent(),
555            }),
556        };
557        let mut observed = lease.observation(key.digest.as_deref().unwrap_or("none"));
558        if let Some(c) = lease.conn() {
559            // Idle: no exchange's tracker or clock is reachable from it.
560            let idle = SentTracker::new();
561            idle.try_abort();
562            *c.hooks.lock().unwrap_or_else(|e| e.into_inner()) = IoHooks {
563                sent: idle,
564                clock: None,
565            };
566        }
567        let eviction = pu.pool.release(lease, result.is_ok());
568        if let Some(o) = &mut observed {
569            o.eviction = eviction;
570        }
571        (result, observed)
572    }
573
574    async fn pooled(
575        &self,
576        lease: &mut Lease<HttpConn>,
577        path: &str,
578        body: Vec<u8>,
579        max: usize,
580        sent: &SentTracker,
581        clock: Option<PhaseClock>,
582    ) -> Result<HttpReply, TransportError> {
583        let failed = |what: &str, e: &dyn fmt::Display| {
584            let d = format!("{what}: {e}");
585            if sent.may_have_sent() {
586                TransportError::Interrupted(d)
587            } else {
588                TransportError::Unsent(d)
589            }
590        };
591        let enter = |p| {
592            if let Some(c) = &clock {
593                c.enter(p);
594            }
595        };
596        let hooks = IoHooks {
597            sent: sent.clone(),
598            clock: clock.clone(),
599        };
600        if lease.conn().is_none() {
601            let tcp = self.connect(&clock).await?;
602            let shared: SharedHooks = Arc::new(std::sync::Mutex::new(hooks.clone()));
603            let sender = match &self.tls {
604                None => {
605                    enter(TransportPhase::RequestWrite);
606                    spawn_http1(TrackedIo::shared(tcp, shared.clone()))
607                        .await
608                        .map_err(|e| failed("http handshake", &e))?
609                }
610                Some(connector) => {
611                    let name = rustls::pki_types::ServerName::try_from(self.endpoint.host.clone())
612                        .map_err(|e| TransportError::Unsent(format!("server name: {e}")))?;
613                    enter(TransportPhase::SecureHandshake);
614                    let tls = connector
615                        .connect(name, tcp)
616                        .await
617                        .map_err(|e| TransportError::Unsent(format!("TLS handshake: {e}")))?;
618                    enter(TransportPhase::RequestWrite);
619                    spawn_http1(TrackedIo::shared(tls, shared.clone()))
620                        .await
621                        .map_err(|e| failed("http handshake", &e))?
622                }
623            };
624            lease.fill(HttpConn {
625                sender,
626                hooks: shared,
627            });
628        } else {
629            // Reuse skips resolution, connect and handshake; their unused
630            // ceilings are not carried over (spec 020, 3.4).
631            enter(TransportPhase::RequestWrite);
632        }
633        let conn = lease.conn_mut().expect("filled above");
634        *conn.hooks.lock().unwrap_or_else(|e| e.into_inner()) = hooks;
635        // A stale connection found closed here has sent nothing.
636        conn.sender
637            .ready()
638            .await
639            .map_err(|e| failed("pooled connection", &e))?;
640        let req = self.request(path, body, clock)?;
641        let resp = conn
642            .sender
643            .send_request(req)
644            .await
645            .map_err(|e| failed("request", &e))?;
646        let status = resp.status();
647        let retry_after = resp
648            .headers()
649            .get(RETRY_AFTER)
650            .map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned());
651        let declared = resp
652            .headers()
653            .get(CONTENT_LENGTH)
654            .and_then(|v| v.to_str().ok())
655            .and_then(|v| v.parse::<u64>().ok());
656        if declared.is_some_and(|n| n > max as u64) {
657            return Err(TransportError::TooLarge { limit: max });
658        }
659        let mut body = resp.into_body();
660        let mut buf = Vec::new();
661        loop {
662            match body.frame().await {
663                None => break,
664                Some(Err(e)) => return Err(failed("response body", &e)),
665                Some(Ok(f)) => {
666                    if let Ok(data) = f.into_data() {
667                        if buf.len() + data.len() > max {
668                            return Err(TransportError::TooLarge { limit: max });
669                        }
670                        buf.extend_from_slice(&data);
671                    }
672                }
673            }
674        }
675        Ok(HttpReply {
676            status: status_code(status),
677            retry_after,
678            body: buf,
679        })
680    }
681}
682
683/// An HTTP/1.1 handshake whose connection is driven by its own task, so the
684/// sender can carry later exchanges.
685async fn spawn_http1<S>(
686    stream: S,
687) -> Result<hyper::client::conn::http1::SendRequest<SignalBody>, hyper::Error>
688where
689    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
690{
691    let (sender, conn) =
692        hyper::client::conn::http1::handshake::<_, SignalBody>(TokioIo::new(stream)).await?;
693    tokio::spawn(async move {
694        let _ = conn.await;
695    });
696    Ok(sender)
697}
698
699/// A request body that tells the phase clock when the connection took it,
700/// so the next completed flush ends the request-write phase (spec 020, 3.4).
701struct SignalBody {
702    inner: Full<Bytes>,
703    clock: Option<PhaseClock>,
704}
705
706impl Body for SignalBody {
707    type Data = Bytes;
708    type Error = std::convert::Infallible;
709
710    fn poll_frame(
711        mut self: std::pin::Pin<&mut Self>,
712        cx: &mut std::task::Context<'_>,
713    ) -> std::task::Poll<Option<Result<Frame<Bytes>, Self::Error>>> {
714        let r = std::pin::Pin::new(&mut self.inner).poll_frame(cx);
715        if r.is_ready()
716            && let Some(c) = &self.clock
717        {
718            c.body_taken();
719        }
720        r
721    }
722
723    fn is_end_stream(&self) -> bool {
724        self.inner.is_end_stream()
725    }
726
727    fn size_hint(&self) -> SizeHint {
728        self.inner.size_hint()
729    }
730}
731
732fn status_code(s: StatusCode) -> u16 {
733    s.as_u16()
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739
740    #[test]
741    fn endpoints_parse_and_plaintext_is_loopback_only() {
742        let e = Endpoint::parse("http://127.0.0.1:8080/base/").unwrap();
743        assert_eq!(
744            (e.scheme, e.host.as_str(), e.port, e.base_path.as_str()),
745            (Scheme::Http, "127.0.0.1", 8080, "/base")
746        );
747        assert!(e.is_loopback());
748        let v6 = Endpoint::parse("http://[::1]:9").unwrap();
749        assert!(v6.is_loopback());
750        assert_eq!(v6.authority(), "[::1]:9");
751        assert_eq!(
752            Endpoint::parse("https://example.invalid").unwrap().port,
753            443
754        );
755        for bad in [
756            "ftp://x",
757            "http://u:p@127.0.0.1",
758            "http://127.0.0.1/?q",
759            "http://:80",
760            "http://127.0.0.1:x",
761        ] {
762            assert!(Endpoint::parse(bad).is_err(), "{bad}");
763        }
764        let far = Endpoint::parse("http://192.0.2.1:80").unwrap();
765        assert_eq!(
766            HttpTransport::new(far, None, None, None).err(),
767            Some(EndpointError::PlaintextNotLoopback)
768        );
769        let tls = Endpoint::parse("https://192.0.2.1").unwrap();
770        assert!(HttpTransport::new(tls, None, None, None).is_ok());
771    }
772
773    #[test]
774    fn secrets_never_print() {
775        let s = Secret::new("SYNTHETIC-TOKEN");
776        assert!(!format!("{s:?}").contains("SYNTHETIC-TOKEN"));
777        let t = HttpTransport::new(
778            Endpoint::parse("http://127.0.0.1:1").unwrap(),
779            None,
780            Some(s),
781            None,
782        )
783        .unwrap();
784        assert!(!format!("{t:?}").contains("SYNTHETIC-TOKEN"));
785    }
786}