use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use super::PushError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushRequest {
pub endpoint: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl PushRequest {
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
}
pub type PushTransportFuture<'a> =
Pin<Box<dyn Future<Output = Result<u16, PushError>> + Send + 'a>>;
pub trait PushTransport: Send + Sync + 'static {
fn deliver<'a>(&'a self, request: &'a PushRequest) -> PushTransportFuture<'a>;
}
impl<T: PushTransport + ?Sized> PushTransport for Arc<T> {
fn deliver<'a>(&'a self, request: &'a PushRequest) -> PushTransportFuture<'a> {
(**self).deliver(request)
}
}
#[derive(Debug, Clone)]
pub struct RecordingPushTransport {
requests: Arc<Mutex<Vec<PushRequest>>>,
statuses: Arc<Mutex<Vec<(String, u16)>>>,
}
impl Default for RecordingPushTransport {
fn default() -> Self {
Self::new()
}
}
impl RecordingPushTransport {
#[must_use]
pub fn new() -> Self {
Self {
requests: Arc::new(Mutex::new(Vec::new())),
statuses: Arc::new(Mutex::new(Vec::new())),
}
}
#[must_use]
pub fn responding_with(self, endpoint: &str, status: u16) -> Self {
self.statuses
.lock()
.expect("recording transport mutex poisoned")
.push((endpoint.to_owned(), status));
self
}
#[must_use]
pub fn requests(&self) -> Vec<PushRequest> {
self.requests
.lock()
.expect("recording transport mutex poisoned")
.clone()
}
}
impl PushTransport for RecordingPushTransport {
fn deliver<'a>(&'a self, request: &'a PushRequest) -> PushTransportFuture<'a> {
Box::pin(async move {
self.requests
.lock()
.expect("recording transport mutex poisoned")
.push(request.clone());
let status = self
.statuses
.lock()
.expect("recording transport mutex poisoned")
.iter()
.find(|(endpoint, _)| *endpoint == request.endpoint)
.map_or(201, |(_, status)| *status);
Ok(status)
})
}
}
#[cfg(feature = "http-client")]
#[derive(Clone)]
pub struct HttpPushTransport {
client: crate::http_client::Client,
}
#[cfg(feature = "http-client")]
impl std::fmt::Debug for HttpPushTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpPushTransport").finish_non_exhaustive()
}
}
#[cfg(feature = "http-client")]
impl HttpPushTransport {
#[must_use]
pub const fn new(client: crate::http_client::Client) -> Self {
Self { client }
}
}
#[cfg(feature = "http-client")]
impl PushTransport for HttpPushTransport {
fn deliver<'a>(&'a self, request: &'a PushRequest) -> PushTransportFuture<'a> {
Box::pin(async move {
let addrs = resolve_and_validate_endpoint(&request.endpoint).await?;
let mut builder = self
.client
.post(&request.endpoint)
.pin_to_addrs(addrs)
.no_redirect()
.discard_response_body();
for (name, value) in &request.headers {
builder = builder.header(name, value);
}
builder
.bytes_body(request.body.clone())
.send()
.await
.map(|response| response.status().as_u16())
.map_err(|e| PushError::Transport(e.to_string()))
})
}
}
#[cfg(feature = "http-client")]
async fn resolve_and_validate_endpoint(
endpoint: &str,
) -> Result<Vec<std::net::SocketAddr>, PushError> {
let parsed = url::Url::parse(endpoint)
.map_err(|e| PushError::InvalidEndpoint(format!("{endpoint}: {e}")))?;
let host = parsed
.host_str()
.ok_or_else(|| PushError::InvalidEndpoint(format!("{endpoint}: no host")))?
.to_owned();
let port = parsed.port_or_known_default().unwrap_or(443);
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host((host.as_str(), port))
.await
.map_err(|e| PushError::Transport(format!("could not resolve the push endpoint: {e}")))?
.collect();
if let Some(blocked) = addrs
.iter()
.find(|addr| crate::http_client::is_blocked_ip(addr.ip()))
{
return Err(PushError::InvalidEndpoint(format!(
"the push endpoint host resolves to a blocked address ({}); refusing to connect",
blocked.ip()
)));
}
if addrs.is_empty() {
return Err(PushError::Transport(
"the push endpoint host resolved to no addresses".to_owned(),
));
}
Ok(addrs)
}
#[cfg(not(feature = "http-client"))]
#[derive(Debug, Clone, Copy)]
pub struct UnavailablePushTransport;
#[cfg(not(feature = "http-client"))]
impl PushTransport for UnavailablePushTransport {
fn deliver<'a>(&'a self, _request: &'a PushRequest) -> PushTransportFuture<'a> {
Box::pin(async {
Err(PushError::Transport(
"no outbound HTTP client is available: autumn-web was built without the \
`http-client` feature. Enable it, or register your own transport."
.to_owned(),
))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request(endpoint: &str) -> PushRequest {
PushRequest {
endpoint: endpoint.to_owned(),
headers: vec![
("authorization".to_owned(), "vapid t=x, k=y".to_owned()),
("content-encoding".to_owned(), "aes128gcm".to_owned()),
],
body: vec![1, 2, 3],
}
}
#[test]
fn header_lookup_is_case_insensitive() {
let request = request("https://push.example.com/a");
assert_eq!(request.header("Authorization"), Some("vapid t=x, k=y"));
assert_eq!(request.header("AUTHORIZATION"), Some("vapid t=x, k=y"));
assert_eq!(request.header("missing"), None);
}
#[tokio::test]
async fn recording_transport_defaults_to_created() {
let transport = RecordingPushTransport::new();
assert_eq!(
transport
.deliver(&request("https://push.example.com/a"))
.await
.expect("deliver"),
201
);
}
#[tokio::test]
async fn recording_transport_applies_a_scripted_status_by_endpoint() {
let transport = RecordingPushTransport::new()
.responding_with("https://push.example.com/dead", 410)
.responding_with("https://push.example.com/busy", 429);
for (endpoint, expected) in [
("https://push.example.com/dead", 410),
("https://push.example.com/busy", 429),
("https://push.example.com/other", 201),
] {
assert_eq!(
transport
.deliver(&request(endpoint))
.await
.expect("deliver"),
expected,
"{endpoint}"
);
}
}
#[tokio::test]
async fn recording_transport_uses_the_first_matching_script() {
let transport = RecordingPushTransport::new()
.responding_with("https://push.example.com/a", 410)
.responding_with("https://push.example.com/a", 500);
assert_eq!(
transport
.deliver(&request("https://push.example.com/a"))
.await
.expect("deliver"),
410
);
}
#[tokio::test]
async fn recording_transport_records_in_dispatch_order_and_shares_across_clones() {
let transport = RecordingPushTransport::new();
let clone = transport.clone();
for endpoint in ["https://push.example.com/1", "https://push.example.com/2"] {
clone.deliver(&request(endpoint)).await.expect("deliver");
}
let recorded = transport.requests();
assert_eq!(recorded.len(), 2, "a clone must share the recording");
assert_eq!(recorded[0].endpoint, "https://push.example.com/1");
assert_eq!(recorded[1].endpoint, "https://push.example.com/2");
assert_eq!(recorded[0].body, vec![1, 2, 3]);
}
#[cfg(feature = "http-client")]
#[tokio::test]
async fn the_http_transport_refuses_an_endpoint_resolving_to_a_blocked_address() {
let err = resolve_and_validate_endpoint("https://localhost/push")
.await
.expect_err("a loopback-resolving host is refused");
assert!(matches!(err, PushError::InvalidEndpoint(_)), "{err:?}");
assert!(
err.to_string().contains("blocked address"),
"the error must say why: {err}"
);
}
#[cfg(feature = "http-client")]
#[tokio::test]
async fn the_http_transport_reports_an_unresolvable_host_as_a_transport_error() {
let err = resolve_and_validate_endpoint("https://nothing.invalid/push")
.await
.expect_err("an unresolvable host fails");
assert!(matches!(err, PushError::Transport(_)), "{err:?}");
}
#[cfg(not(feature = "http-client"))]
#[tokio::test]
async fn the_unavailable_transport_errors_rather_than_pretending_to_deliver() {
let err = UnavailablePushTransport
.deliver(&request("https://push.example.com/a"))
.await
.expect_err("a push that cannot go out must never look like it did");
assert!(matches!(err, PushError::Transport(_)), "{err:?}");
}
}