atagen

malice

public
Star0
Watch0
0bc1aca195fb7ffce50e1865090cbdfcbf999f63
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: crates/malice-push/src/send.rs

aa3dce5114d0atagen2026-10-26 06:09 UTC1
use std::{sync::Arc, time::Duration};
aa3dce5114d0atagen2026-10-26 06:09 UTC2
aa3dce5114d0atagen2026-10-26 06:09 UTC3
use aws_lc_rs::digest::{SHA256, digest};
aa3dce5114d0atagen2026-10-26 06:09 UTC4
use base64::{
aa3dce5114d0atagen2026-10-26 06:09 UTC5
    Engine,
aa3dce5114d0atagen2026-10-26 06:09 UTC6
    engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD},
aa3dce5114d0atagen2026-10-26 06:09 UTC7
};
aa3dce5114d0atagen2026-10-26 06:09 UTC8
use reqwest::{StatusCode, header};
aa3dce5114d0atagen2026-10-26 06:09 UTC9
use serde::{Deserialize, Serialize};
aa3dce5114d0atagen2026-10-26 06:09 UTC10
use url::Url;
aa3dce5114d0atagen2026-10-26 06:09 UTC11
aa3dce5114d0atagen2026-10-26 06:09 UTC12
use crate::{
aa3dce5114d0atagen2026-10-26 06:09 UTC13
    ece::{self, EceError},
aa3dce5114d0atagen2026-10-26 06:09 UTC14
    vapid::Vapid,
aa3dce5114d0atagen2026-10-26 06:09 UTC15
};
aa3dce5114d0atagen2026-10-26 06:09 UTC16
aa3dce5114d0atagen2026-10-26 06:09 UTC17
const PUSH_HOSTS: &[&str] = &[
aa3dce5114d0atagen2026-10-26 06:09 UTC18
    "*.push.apple.com",
aa3dce5114d0atagen2026-10-26 06:09 UTC19
    "fcm.googleapis.com",
aa3dce5114d0atagen2026-10-26 06:09 UTC20
    "updates.push.services.mozilla.com",
aa3dce5114d0atagen2026-10-26 06:09 UTC21
    "*.notify.windows.com",
aa3dce5114d0atagen2026-10-26 06:09 UTC22
];
aa3dce5114d0atagen2026-10-26 06:09 UTC23
const ALLOW_HOSTS_ENV: &str = "MALICE_PUSH_ALLOW_HOSTS";
aa3dce5114d0atagen2026-10-26 06:09 UTC24
const MAX_ENDPOINT_LEN: usize = 2048;
aa3dce5114d0atagen2026-10-26 06:09 UTC25
const TTL_SECS: u32 = 12 * 60 * 60;
aa3dce5114d0atagen2026-10-26 06:09 UTC26
aa3dce5114d0atagen2026-10-26 06:09 UTC27
/// A browser's `PushSubscription`, keys as the browser's `toJSON` gives them.
aa3dce5114d0atagen2026-10-26 06:09 UTC28
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
aa3dce5114d0atagen2026-10-26 06:09 UTC29
pub struct Subscription {
aa3dce5114d0atagen2026-10-26 06:09 UTC30
    pub endpoint: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC31
    pub p256dh: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC32
    pub auth: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC33
}
aa3dce5114d0atagen2026-10-26 06:09 UTC34
aa3dce5114d0atagen2026-10-26 06:09 UTC35
impl Subscription {
aa3dce5114d0atagen2026-10-26 06:09 UTC36
    /// Whether the keys decode to a P-256 point and a 16-byte secret.
aa3dce5114d0atagen2026-10-26 06:09 UTC37
    pub fn keys_valid(&self) -> bool {
aa3dce5114d0atagen2026-10-26 06:09 UTC38
        matches!(
aa3dce5114d0atagen2026-10-26 06:09 UTC39
            (decode_key(&self.p256dh), decode_key(&self.auth)),
aa3dce5114d0atagen2026-10-26 06:09 UTC40
            (Some(p256dh), Some(auth)) if p256dh.len() == 65 && p256dh[0] == 4 && auth.len() == 16
aa3dce5114d0atagen2026-10-26 06:09 UTC41
        )
aa3dce5114d0atagen2026-10-26 06:09 UTC42
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC43
}
aa3dce5114d0atagen2026-10-26 06:09 UTC44
aa3dce5114d0atagen2026-10-26 06:09 UTC45
fn decode_key(value: &str) -> Option<Vec<u8>> {
aa3dce5114d0atagen2026-10-26 06:09 UTC46
    let value = value.trim().trim_end_matches('=');
aa3dce5114d0atagen2026-10-26 06:09 UTC47
    URL_SAFE_NO_PAD
aa3dce5114d0atagen2026-10-26 06:09 UTC48
        .decode(value)
aa3dce5114d0atagen2026-10-26 06:09 UTC49
        .or_else(|_| STANDARD_NO_PAD.decode(value))
aa3dce5114d0atagen2026-10-26 06:09 UTC50
        .ok()
aa3dce5114d0atagen2026-10-26 06:09 UTC51
}
aa3dce5114d0atagen2026-10-26 06:09 UTC52
aa3dce5114d0atagen2026-10-26 06:09 UTC53
/// Push service hosts this server will POST to, so a pusher can't aim it at
aa3dce5114d0atagen2026-10-26 06:09 UTC54
/// an arbitrary URL.
aa3dce5114d0atagen2026-10-26 06:09 UTC55
#[derive(Debug, Clone, Default)]
aa3dce5114d0atagen2026-10-26 06:09 UTC56
pub struct Allowlist {
aa3dce5114d0atagen2026-10-26 06:09 UTC57
    /// `host:port` authorities also allowed over plain http, for local testing
aa3dce5114d0atagen2026-10-26 06:09 UTC58
    extra: Vec<String>,
aa3dce5114d0atagen2026-10-26 06:09 UTC59
}
aa3dce5114d0atagen2026-10-26 06:09 UTC60
aa3dce5114d0atagen2026-10-26 06:09 UTC61
impl Allowlist {
aa3dce5114d0atagen2026-10-26 06:09 UTC62
    /// The built-in push services, plus any `host:port` entries in
aa3dce5114d0atagen2026-10-26 06:09 UTC63
    /// `MALICE_PUSH_ALLOW_HOSTS`.
aa3dce5114d0atagen2026-10-26 06:09 UTC64
    pub fn from_env() -> Self {
aa3dce5114d0atagen2026-10-26 06:09 UTC65
        let extra = std::env::var(ALLOW_HOSTS_ENV)
aa3dce5114d0atagen2026-10-26 06:09 UTC66
            .unwrap_or_default()
aa3dce5114d0atagen2026-10-26 06:09 UTC67
            .split(',')
aa3dce5114d0atagen2026-10-26 06:09 UTC68
            .map(|host| host.trim().to_ascii_lowercase())
aa3dce5114d0atagen2026-10-26 06:09 UTC69
            .filter(|host| !host.is_empty())
aa3dce5114d0atagen2026-10-26 06:09 UTC70
            .collect::<Vec<_>>();
aa3dce5114d0atagen2026-10-26 06:09 UTC71
        if !extra.is_empty() {
aa3dce5114d0atagen2026-10-26 06:09 UTC72
            tracing::warn!(hosts = ?extra, "{ALLOW_HOSTS_ENV} allows extra push hosts, for testing only");
aa3dce5114d0atagen2026-10-26 06:09 UTC73
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC74
        Self { extra }
aa3dce5114d0atagen2026-10-26 06:09 UTC75
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC76
aa3dce5114d0atagen2026-10-26 06:09 UTC77
    pub fn with_extra(extra: impl IntoIterator<Item = String>) -> Self {
aa3dce5114d0atagen2026-10-26 06:09 UTC78
        Self {
aa3dce5114d0atagen2026-10-26 06:09 UTC79
            extra: extra.into_iter().collect(),
aa3dce5114d0atagen2026-10-26 06:09 UTC80
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC81
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC82
aa3dce5114d0atagen2026-10-26 06:09 UTC83
    pub fn check(&self, endpoint: &str) -> Option<Url> {
aa3dce5114d0atagen2026-10-26 06:09 UTC84
        if endpoint.len() > MAX_ENDPOINT_LEN {
aa3dce5114d0atagen2026-10-26 06:09 UTC85
            return None;
aa3dce5114d0atagen2026-10-26 06:09 UTC86
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC87
        let url = Url::parse(endpoint).ok()?;
aa3dce5114d0atagen2026-10-26 06:09 UTC88
        if !url.username().is_empty() || url.password().is_some() {
aa3dce5114d0atagen2026-10-26 06:09 UTC89
            return None;
aa3dce5114d0atagen2026-10-26 06:09 UTC90
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC91
        let port = url.port_or_known_default()?;
aa3dce5114d0atagen2026-10-26 06:09 UTC92
        let builtin = url.scheme() == "https"
aa3dce5114d0atagen2026-10-26 06:09 UTC93
            && port == 443
aa3dce5114d0atagen2026-10-26 06:09 UTC94
            && url.domain().is_some_and(|host| {
aa3dce5114d0atagen2026-10-26 06:09 UTC95
                PUSH_HOSTS
aa3dce5114d0atagen2026-10-26 06:09 UTC96
                    .iter()
aa3dce5114d0atagen2026-10-26 06:09 UTC97
                    .any(|pattern| match pattern.strip_prefix('*') {
aa3dce5114d0atagen2026-10-26 06:09 UTC98
                        Some(suffix) => host.len() > suffix.len() && host.ends_with(suffix),
aa3dce5114d0atagen2026-10-26 06:09 UTC99
                        None => host == *pattern,
aa3dce5114d0atagen2026-10-26 06:09 UTC100
                    })
aa3dce5114d0atagen2026-10-26 06:09 UTC101
            });
aa3dce5114d0atagen2026-10-26 06:09 UTC102
        let extra = matches!(url.scheme(), "http" | "https")
aa3dce5114d0atagen2026-10-26 06:09 UTC103
            && self.extra.contains(&format!("{}:{port}", url.host_str()?));
aa3dce5114d0atagen2026-10-26 06:09 UTC104
        (builtin || extra).then_some(url)
aa3dce5114d0atagen2026-10-26 06:09 UTC105
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC106
}
aa3dce5114d0atagen2026-10-26 06:09 UTC107
aa3dce5114d0atagen2026-10-26 06:09 UTC108
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
aa3dce5114d0atagen2026-10-26 06:09 UTC109
pub enum Urgency {
aa3dce5114d0atagen2026-10-26 06:09 UTC110
    Normal,
aa3dce5114d0atagen2026-10-26 06:09 UTC111
    High,
aa3dce5114d0atagen2026-10-26 06:09 UTC112
}
aa3dce5114d0atagen2026-10-26 06:09 UTC113
aa3dce5114d0atagen2026-10-26 06:09 UTC114
pub struct Message<'a> {
aa3dce5114d0atagen2026-10-26 06:09 UTC115
    pub payload: &'a [u8],
aa3dce5114d0atagen2026-10-26 06:09 UTC116
    pub urgency: Urgency,
aa3dce5114d0atagen2026-10-26 06:09 UTC117
    /// pushes sharing a topic replace each other while still queued
aa3dce5114d0atagen2026-10-26 06:09 UTC118
    pub topic: &'a str,
aa3dce5114d0atagen2026-10-26 06:09 UTC119
}
aa3dce5114d0atagen2026-10-26 06:09 UTC120
aa3dce5114d0atagen2026-10-26 06:09 UTC121
#[derive(Debug, Clone, PartialEq, Eq)]
aa3dce5114d0atagen2026-10-26 06:09 UTC122
pub enum Outcome {
aa3dce5114d0atagen2026-10-26 06:09 UTC123
    Delivered,
aa3dce5114d0atagen2026-10-26 06:09 UTC124
    /// the push service no longer knows the subscription (404 or 410)
aa3dce5114d0atagen2026-10-26 06:09 UTC125
    Gone,
aa3dce5114d0atagen2026-10-26 06:09 UTC126
    /// the endpoint is not an allowed push service, or the keys are unusable
aa3dce5114d0atagen2026-10-26 06:09 UTC127
    Refused,
aa3dce5114d0atagen2026-10-26 06:09 UTC128
    Retryable,
aa3dce5114d0atagen2026-10-26 06:09 UTC129
    Failed(String),
aa3dce5114d0atagen2026-10-26 06:09 UTC130
}
aa3dce5114d0atagen2026-10-26 06:09 UTC131
aa3dce5114d0atagen2026-10-26 06:09 UTC132
impl Outcome {
aa3dce5114d0atagen2026-10-26 06:09 UTC133
    pub fn drops_subscription(&self) -> bool {
aa3dce5114d0atagen2026-10-26 06:09 UTC134
        matches!(self, Self::Gone | Self::Refused)
aa3dce5114d0atagen2026-10-26 06:09 UTC135
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC136
}
aa3dce5114d0atagen2026-10-26 06:09 UTC137
aa3dce5114d0atagen2026-10-26 06:09 UTC138
/// Encrypts and delivers pushes, signed with this server's VAPID key.
aa3dce5114d0atagen2026-10-26 06:09 UTC139
#[derive(Clone)]
aa3dce5114d0atagen2026-10-26 06:09 UTC140
pub struct Sender {
aa3dce5114d0atagen2026-10-26 06:09 UTC141
    http: reqwest::Client,
aa3dce5114d0atagen2026-10-26 06:09 UTC142
    vapid: Arc<Vapid>,
aa3dce5114d0atagen2026-10-26 06:09 UTC143
    contact: String,
aa3dce5114d0atagen2026-10-26 06:09 UTC144
    allow: Allowlist,
aa3dce5114d0atagen2026-10-26 06:09 UTC145
}
aa3dce5114d0atagen2026-10-26 06:09 UTC146
aa3dce5114d0atagen2026-10-26 06:09 UTC147
impl Sender {
aa3dce5114d0atagen2026-10-26 06:09 UTC148
    /// `contact` is the VAPID `sub`, a `mailto:` or `https:` URL.
aa3dce5114d0atagen2026-10-26 06:09 UTC149
    pub fn new(vapid: Vapid, contact: String, allow: Allowlist) -> Result<Self, String> {
aa3dce5114d0atagen2026-10-26 06:09 UTC150
        let http = reqwest::Client::builder()
aa3dce5114d0atagen2026-10-26 06:09 UTC151
            .redirect(reqwest::redirect::Policy::none())
aa3dce5114d0atagen2026-10-26 06:09 UTC152
            .connect_timeout(Duration::from_secs(5))
aa3dce5114d0atagen2026-10-26 06:09 UTC153
            .timeout(Duration::from_secs(10))
aa3dce5114d0atagen2026-10-26 06:09 UTC154
            .build()
aa3dce5114d0atagen2026-10-26 06:09 UTC155
            .map_err(|error| format!("building the push client: {error}"))?;
aa3dce5114d0atagen2026-10-26 06:09 UTC156
        Ok(Self {
aa3dce5114d0atagen2026-10-26 06:09 UTC157
            http,
aa3dce5114d0atagen2026-10-26 06:09 UTC158
            vapid: Arc::new(vapid),
aa3dce5114d0atagen2026-10-26 06:09 UTC159
            contact,
aa3dce5114d0atagen2026-10-26 06:09 UTC160
            allow,
aa3dce5114d0atagen2026-10-26 06:09 UTC161
        })
aa3dce5114d0atagen2026-10-26 06:09 UTC162
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC163
aa3dce5114d0atagen2026-10-26 06:09 UTC164
    pub fn vapid_public_key(&self) -> &str {
aa3dce5114d0atagen2026-10-26 06:09 UTC165
        self.vapid.public_key()
aa3dce5114d0atagen2026-10-26 06:09 UTC166
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC167
aa3dce5114d0atagen2026-10-26 06:09 UTC168
    pub fn allows(&self, endpoint: &str) -> bool {
aa3dce5114d0atagen2026-10-26 06:09 UTC169
        self.allow.check(endpoint).is_some()
aa3dce5114d0atagen2026-10-26 06:09 UTC170
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC171
aa3dce5114d0atagen2026-10-26 06:09 UTC172
    /// Sends once, and once more after a second if the push service asks to retry.
aa3dce5114d0atagen2026-10-26 06:09 UTC173
    pub async fn send(&self, subscription: &Subscription, message: &Message<'_>) -> Outcome {
aa3dce5114d0atagen2026-10-26 06:09 UTC174
        let Some(url) = self.allow.check(&subscription.endpoint) else {
aa3dce5114d0atagen2026-10-26 06:09 UTC175
            return Outcome::Refused;
aa3dce5114d0atagen2026-10-26 06:09 UTC176
        };
aa3dce5114d0atagen2026-10-26 06:09 UTC177
        let (Some(p256dh), Some(auth)) = (
aa3dce5114d0atagen2026-10-26 06:09 UTC178
            decode_key(&subscription.p256dh),
aa3dce5114d0atagen2026-10-26 06:09 UTC179
            decode_key(&subscription.auth),
aa3dce5114d0atagen2026-10-26 06:09 UTC180
        ) else {
aa3dce5114d0atagen2026-10-26 06:09 UTC181
            return Outcome::Refused;
aa3dce5114d0atagen2026-10-26 06:09 UTC182
        };
aa3dce5114d0atagen2026-10-26 06:09 UTC183
        let body = match ece::encrypt(&p256dh, &auth, message.payload) {
aa3dce5114d0atagen2026-10-26 06:09 UTC184
            Ok(body) => body,
aa3dce5114d0atagen2026-10-26 06:09 UTC185
            Err(EceError::BadKey) => return Outcome::Refused,
aa3dce5114d0atagen2026-10-26 06:09 UTC186
            Err(error) => return Outcome::Failed(error.to_string()),
aa3dce5114d0atagen2026-10-26 06:09 UTC187
        };
aa3dce5114d0atagen2026-10-26 06:09 UTC188
        let authorization = match self
aa3dce5114d0atagen2026-10-26 06:09 UTC189
            .vapid
aa3dce5114d0atagen2026-10-26 06:09 UTC190
            .authorization(&url.origin().ascii_serialization(), &self.contact)
aa3dce5114d0atagen2026-10-26 06:09 UTC191
        {
aa3dce5114d0atagen2026-10-26 06:09 UTC192
            Ok(authorization) => authorization,
aa3dce5114d0atagen2026-10-26 06:09 UTC193
            Err(error) => return Outcome::Failed(error),
aa3dce5114d0atagen2026-10-26 06:09 UTC194
        };
aa3dce5114d0atagen2026-10-26 06:09 UTC195
        let topic = topic(message.topic);
aa3dce5114d0atagen2026-10-26 06:09 UTC196
        let urgency = match message.urgency {
aa3dce5114d0atagen2026-10-26 06:09 UTC197
            Urgency::Normal => "normal",
aa3dce5114d0atagen2026-10-26 06:09 UTC198
            Urgency::High => "high",
aa3dce5114d0atagen2026-10-26 06:09 UTC199
        };
aa3dce5114d0atagen2026-10-26 06:09 UTC200
        for attempt in 0..2 {
aa3dce5114d0atagen2026-10-26 06:09 UTC201
            if attempt > 0 {
aa3dce5114d0atagen2026-10-26 06:09 UTC202
                tokio::time::sleep(Duration::from_secs(1)).await;
aa3dce5114d0atagen2026-10-26 06:09 UTC203
            }
aa3dce5114d0atagen2026-10-26 06:09 UTC204
            let response = self
aa3dce5114d0atagen2026-10-26 06:09 UTC205
                .http
aa3dce5114d0atagen2026-10-26 06:09 UTC206
                .post(url.clone())
aa3dce5114d0atagen2026-10-26 06:09 UTC207
                .header(header::AUTHORIZATION, &authorization)
aa3dce5114d0atagen2026-10-26 06:09 UTC208
                .header(header::CONTENT_ENCODING, "aes128gcm")
aa3dce5114d0atagen2026-10-26 06:09 UTC209
                .header(header::CONTENT_TYPE, "application/octet-stream")
aa3dce5114d0atagen2026-10-26 06:09 UTC210
                .header("TTL", TTL_SECS)
aa3dce5114d0atagen2026-10-26 06:09 UTC211
                .header("Urgency", urgency)
aa3dce5114d0atagen2026-10-26 06:09 UTC212
                .header("Topic", &topic)
aa3dce5114d0atagen2026-10-26 06:09 UTC213
                .body(body.clone())
aa3dce5114d0atagen2026-10-26 06:09 UTC214
                .send()
aa3dce5114d0atagen2026-10-26 06:09 UTC215
                .await;
aa3dce5114d0atagen2026-10-26 06:09 UTC216
            let outcome = match response {
aa3dce5114d0atagen2026-10-26 06:09 UTC217
                Ok(response) => classify(response.status()),
aa3dce5114d0atagen2026-10-26 06:09 UTC218
                Err(error) => {
aa3dce5114d0atagen2026-10-26 06:09 UTC219
                    tracing::debug!(%error, "push service unreachable");
aa3dce5114d0atagen2026-10-26 06:09 UTC220
                    Outcome::Retryable
aa3dce5114d0atagen2026-10-26 06:09 UTC221
                }
aa3dce5114d0atagen2026-10-26 06:09 UTC222
            };
aa3dce5114d0atagen2026-10-26 06:09 UTC223
            if outcome != Outcome::Retryable {
aa3dce5114d0atagen2026-10-26 06:09 UTC224
                return outcome;
aa3dce5114d0atagen2026-10-26 06:09 UTC225
            }
aa3dce5114d0atagen2026-10-26 06:09 UTC226
        }
aa3dce5114d0atagen2026-10-26 06:09 UTC227
        Outcome::Retryable
aa3dce5114d0atagen2026-10-26 06:09 UTC228
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC229
}
aa3dce5114d0atagen2026-10-26 06:09 UTC230
aa3dce5114d0atagen2026-10-26 06:09 UTC231
fn classify(status: StatusCode) -> Outcome {
aa3dce5114d0atagen2026-10-26 06:09 UTC232
    match status {
aa3dce5114d0atagen2026-10-26 06:09 UTC233
        status if status.is_success() => Outcome::Delivered,
aa3dce5114d0atagen2026-10-26 06:09 UTC234
        StatusCode::NOT_FOUND | StatusCode::GONE => Outcome::Gone,
aa3dce5114d0atagen2026-10-26 06:09 UTC235
        StatusCode::TOO_MANY_REQUESTS => Outcome::Retryable,
aa3dce5114d0atagen2026-10-26 06:09 UTC236
        status if status.is_server_error() => Outcome::Retryable,
aa3dce5114d0atagen2026-10-26 06:09 UTC237
        status => Outcome::Failed(format!("push service answered {status}")),
aa3dce5114d0atagen2026-10-26 06:09 UTC238
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC239
}
aa3dce5114d0atagen2026-10-26 06:09 UTC240
aa3dce5114d0atagen2026-10-26 06:09 UTC241
/// RFC 8030 limits a topic to 32 characters of the base64url alphabet.
aa3dce5114d0atagen2026-10-26 06:09 UTC242
fn topic(raw: &str) -> String {
aa3dce5114d0atagen2026-10-26 06:09 UTC243
    let mut topic = URL_SAFE_NO_PAD.encode(digest(&SHA256, raw.as_bytes()));
aa3dce5114d0atagen2026-10-26 06:09 UTC244
    topic.truncate(32);
aa3dce5114d0atagen2026-10-26 06:09 UTC245
    topic
aa3dce5114d0atagen2026-10-26 06:09 UTC246
}
aa3dce5114d0atagen2026-10-26 06:09 UTC247
aa3dce5114d0atagen2026-10-26 06:09 UTC248
#[cfg(test)]
aa3dce5114d0atagen2026-10-26 06:09 UTC249
mod tests {
aa3dce5114d0atagen2026-10-26 06:09 UTC250
    use super::*;
aa3dce5114d0atagen2026-10-26 06:09 UTC251
aa3dce5114d0atagen2026-10-26 06:09 UTC252
    #[test]
aa3dce5114d0atagen2026-10-26 06:09 UTC253
    fn allowlist_admits_push_services_only() {
aa3dce5114d0atagen2026-10-26 06:09 UTC254
        let allow = Allowlist::default();
aa3dce5114d0atagen2026-10-26 06:09 UTC255
        assert!(
aa3dce5114d0atagen2026-10-26 06:09 UTC256
            allow
aa3dce5114d0atagen2026-10-26 06:09 UTC257
                .check("https://fcm.googleapis.com/fcm/send/abc")
aa3dce5114d0atagen2026-10-26 06:09 UTC258
                .is_some()
aa3dce5114d0atagen2026-10-26 06:09 UTC259
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC260
        assert!(allow.check("https://web.push.apple.com/QGx").is_some());
aa3dce5114d0atagen2026-10-26 06:09 UTC261
        assert!(
aa3dce5114d0atagen2026-10-26 06:09 UTC262
            allow
aa3dce5114d0atagen2026-10-26 06:09 UTC263
                .check("https://wns2-par02p.notify.windows.com/w/?token=x")
aa3dce5114d0atagen2026-10-26 06:09 UTC264
                .is_some()
aa3dce5114d0atagen2026-10-26 06:09 UTC265
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC266
        assert!(
aa3dce5114d0atagen2026-10-26 06:09 UTC267
            allow
aa3dce5114d0atagen2026-10-26 06:09 UTC268
                .check("https://updates.push.services.mozilla.com/wpush/v2/x")
aa3dce5114d0atagen2026-10-26 06:09 UTC269
                .is_some()
aa3dce5114d0atagen2026-10-26 06:09 UTC270
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC271
        assert!(allow.check("http://fcm.googleapis.com/x").is_none());
aa3dce5114d0atagen2026-10-26 06:09 UTC272
        assert!(allow.check("https://fcm.googleapis.com:8443/x").is_none());
aa3dce5114d0atagen2026-10-26 06:09 UTC273
        assert!(allow.check("https://push.apple.com/x").is_none());
aa3dce5114d0atagen2026-10-26 06:09 UTC274
        assert!(allow.check("https://evilpush.apple.com/x").is_none());
aa3dce5114d0atagen2026-10-26 06:09 UTC275
        assert!(
aa3dce5114d0atagen2026-10-26 06:09 UTC276
            allow
aa3dce5114d0atagen2026-10-26 06:09 UTC277
                .check("https://fcm.googleapis.com.evil.org/x")
aa3dce5114d0atagen2026-10-26 06:09 UTC278
                .is_none()
aa3dce5114d0atagen2026-10-26 06:09 UTC279
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC280
        assert!(allow.check("https://user@fcm.googleapis.com/x").is_none());
aa3dce5114d0atagen2026-10-26 06:09 UTC281
        assert!(allow.check("https://127.0.0.1/x").is_none());
aa3dce5114d0atagen2026-10-26 06:09 UTC282
        assert!(allow.check("http://127.0.0.1:9000/x").is_none());
aa3dce5114d0atagen2026-10-26 06:09 UTC283
        let allow = Allowlist::with_extra(["localhost:9000".to_owned()]);
aa3dce5114d0atagen2026-10-26 06:09 UTC284
        assert!(allow.check("http://localhost:9000/x").is_some());
aa3dce5114d0atagen2026-10-26 06:09 UTC285
        assert!(allow.check("http://localhost:9001/x").is_none());
aa3dce5114d0atagen2026-10-26 06:09 UTC286
        let allow = Allowlist::with_extra(["127.0.0.1:9000".to_owned()]);
aa3dce5114d0atagen2026-10-26 06:09 UTC287
        assert!(allow.check("http://127.0.0.1:9000/x").is_some());
aa3dce5114d0atagen2026-10-26 06:09 UTC288
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC289
aa3dce5114d0atagen2026-10-26 06:09 UTC290
    #[test]
aa3dce5114d0atagen2026-10-26 06:09 UTC291
    fn topics_fit_rfc8030() {
aa3dce5114d0atagen2026-10-26 06:09 UTC292
        let topic = topic("!room:example.org");
aa3dce5114d0atagen2026-10-26 06:09 UTC293
        assert_eq!(topic.len(), 32);
aa3dce5114d0atagen2026-10-26 06:09 UTC294
        assert!(
aa3dce5114d0atagen2026-10-26 06:09 UTC295
            topic
aa3dce5114d0atagen2026-10-26 06:09 UTC296
                .chars()
aa3dce5114d0atagen2026-10-26 06:09 UTC297
                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
aa3dce5114d0atagen2026-10-26 06:09 UTC298
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC299
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC300
aa3dce5114d0atagen2026-10-26 06:09 UTC301
    #[test]
aa3dce5114d0atagen2026-10-26 06:09 UTC302
    fn keys_validate() {
aa3dce5114d0atagen2026-10-26 06:09 UTC303
        let good = Subscription {
aa3dce5114d0atagen2026-10-26 06:09 UTC304
            endpoint: String::new(),
aa3dce5114d0atagen2026-10-26 06:09 UTC305
            p256dh: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC306
            auth: "BTBZMqHH6r4Tts7J_aSIgg==".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC307
        };
aa3dce5114d0atagen2026-10-26 06:09 UTC308
        assert!(good.keys_valid());
aa3dce5114d0atagen2026-10-26 06:09 UTC309
        assert!(
aa3dce5114d0atagen2026-10-26 06:09 UTC310
            !Subscription {
aa3dce5114d0atagen2026-10-26 06:09 UTC311
                auth: "short".to_owned(),
aa3dce5114d0atagen2026-10-26 06:09 UTC312
                ..good
aa3dce5114d0atagen2026-10-26 06:09 UTC313
            }
aa3dce5114d0atagen2026-10-26 06:09 UTC314
            .keys_valid()
aa3dce5114d0atagen2026-10-26 06:09 UTC315
        );
aa3dce5114d0atagen2026-10-26 06:09 UTC316
    }
aa3dce5114d0atagen2026-10-26 06:09 UTC317
}