Skip to main content

cratefield_adapter_webpush/
lib.rs

1//! `cratefield-adapter-webpush`: the [`Push`] port over Web Push
2//! (issue #180) — [RFC 8030] delivery, [RFC 8188]/[RFC 8291] `aes128gcm`
3//! payload encryption, [RFC 8292] VAPID authentication. Uses the runtime's
4//! [`HttpClient`] and [`Clock`] ports — no vendor SDK, no `reqwest`, no
5//! OpenSSL — so the same adapter runs on Workers (`worker::Fetch`) and
6//! natively.
7//!
8//! **One adapter, two audiences.** A browser subscription and a
9//! UnifiedPush endpoint are the *same protocol*: a UnifiedPush distributor
10//! (ntfy, `NextPush`, Sunup) hands the app an endpoint that accepts exactly
11//! the RFC 8030 request a browser push service accepts. So this adapter
12//! serves Google-free Android as well as Chrome, Firefox, Edge and Safari,
13//! and nothing in it knows which it is talking to (ADR 0015: the transport
14//! is a fact about the recipient, not about the device).
15//!
16//! **Recipients.** It serves [`Recipient::WebPush`] and returns
17//! [`PushError::unsupported_recipient`] for APNs and FCM. A venture with
18//! more than one transport wires
19//! [`RoutingPush`](cratefield_core::RoutingPush), which dispatches by
20//! variant.
21//!
22//! **Encryption.** Every message gets a fresh P-256 key pair and a fresh
23//! 16-byte salt, combined with the subscription's `p256dh` by ECDH and with
24//! its `auth` secret by HKDF, then one AES-128-GCM record (module
25//! [`ece`]). The push service sees ciphertext; only the browser that
26//! created the subscription holds the private half. The RFC 8291 Appendix A
27//! and RFC 8188 §3.1 vectors are reproduced byte for byte in that module's
28//! tests, and `tests/rfc8291.rs` decrypts from the user agent's side so a
29//! regression cannot pass by matching a vector alone.
30//!
31//! **Authentication.** VAPID (module [`vapid`]) is an ES256 JWT whose `aud`
32//! is the push service's origin, signed by [`cratefield_push_auth`] and
33//! cached per origin — the audience differs per vendor, so one cached token
34//! would be wrong for all but one of them.
35//!
36//! **Degraded mode.** [`WebPush::not_configured`] reports
37//! [`PushOutcome::NotConfigured`] without any network call, the same
38//! contract the other adapters use, so a venture with no VAPID key still
39//! builds and runs. It answers that only for the transport it serves: an
40//! APNs or FCM recipient is still [`PushError::unsupported_recipient`],
41//! configured or not.
42//!
43//! # `Unregistered` is a delete instruction
44//!
45//! [`PushError::Unregistered`] does not mean "this send failed". It is the
46//! one error in the port that tells the caller to **destroy** the recipient,
47//! and it is far too easy to over-apply — this adapter and its FCM sibling
48//! each mapped an extra status onto it independently, before either was
49//! reviewed.
50//!
51//! It costs more here than anywhere else in the port. An APNs device token
52//! or an FCM registration token is re-registered by the app on its next
53//! launch, unattended; a Web Push subscription can be recreated **only** by
54//! the browser calling `pushManager.subscribe()`, which needs the user back
55//! on the site with notification permission still granted. Pruning a live
56//! subscription is not a lost message, it is a lost subscriber.
57//!
58//! So the bar is a status whose *only* meaning is "gone": RFC 8030 §5
59//! defines exactly one, `410 Gone`, and that is the only one mapped. A
60//! status that a misconfigured proxy, a stale ingress rule or a rate limiter
61//! can also produce is retried, however often it happens to mean a dead
62//! subscription in practice. When in doubt, retry: the cost of a wrong
63//! `Transient` is some wasted sends, and the cost of a wrong `Unregistered`
64//! cannot be undone from the server at all.
65//!
66//! [RFC 8030]: https://www.rfc-editor.org/rfc/rfc8030
67//! [RFC 8188]: https://www.rfc-editor.org/rfc/rfc8188
68//! [RFC 8291]: https://www.rfc-editor.org/rfc/rfc8291
69//! [RFC 8292]: https://www.rfc-editor.org/rfc/rfc8292
70#![doc = include_str!("../README.md")]
71#![forbid(unsafe_code)]
72
73pub mod ece;
74pub mod vapid;
75
76use std::sync::Arc;
77use std::time::Duration;
78
79use async_trait::async_trait;
80use base64::Engine as _;
81use base64::engine::general_purpose::URL_SAFE_NO_PAD;
82use bytes::Bytes;
83use cratefield_core::{
84    Clock, HttpClient, Notification, Priority, Push, PushError, PushOutcome, Recipient, ttl_secs,
85};
86use http::header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_TYPE, LOCATION};
87use http::{Request, StatusCode};
88use serde_json::{Map, Value};
89use sha2::{Digest as _, Sha256};
90
91use crate::ece::{Ece, EceError, SubscriptionKeys};
92use crate::vapid::{Vapid, VapidError, VapidKeys};
93
94/// How long a push service holds an undelivered notification when the caller
95/// names no TTL.
96///
97/// RFC 8030 §5.2 makes the `TTL` header **mandatory** — there is no "unset"
98/// on the wire — so an adapter has to choose. A day is the same default the
99/// browser tooling uses, and it is the answer that loses nothing: a `0` TTL
100/// (deliver only if the device is online now) is a deliberate instruction
101/// and is passed through when a caller asks for it.
102#[allow(clippy::duration_suboptimal_units)] // `from_hours` is not const-stable on 1.98
103pub const DEFAULT_TTL: Duration = Duration::from_secs(24 * 3_600);
104
105/// RFC 8030 §5.4: a `Topic` is 1–32 characters from the URL-safe base64
106/// alphabet.
107pub const MAX_TOPIC_LEN: usize = 32;
108
109/// Building a [`WebPush`] adapter failed.
110#[derive(Debug, thiserror::Error)]
111pub enum WebPushConfigError {
112    /// The VAPID key or subject was not usable.
113    #[error(transparent)]
114    Vapid(#[from] VapidError),
115    /// The record size was outside RFC 8188's bounds.
116    #[error(transparent)]
117    Ece(#[from] EceError),
118}
119
120/// [`Push`] over `POST <subscription endpoint>`.
121pub struct WebPush {
122    inner: Inner,
123}
124
125enum Inner {
126    Live(Box<Live>),
127    NotConfigured,
128}
129
130struct Live {
131    http: Arc<dyn HttpClient>,
132    clock: Arc<dyn Clock>,
133    vapid: Vapid,
134    ece: Ece,
135}
136
137impl WebPush {
138    /// Builds a live adapter, parsing the VAPID key up front so a malformed
139    /// one is reported at construction rather than on the first send.
140    ///
141    /// [`VapidKeys`] carries the `VAPID_PRIVATE_KEY` (a PKCS#8 PEM or the
142    /// bare 32-byte P-256 scalar base64url) and the `VAPID_SUBJECT` contact
143    /// URI. The public key is derived, never configured, so the pair cannot
144    /// drift.
145    ///
146    /// # Errors
147    ///
148    /// [`WebPushConfigError::Vapid`] if the key or the subject is not
149    /// usable.
150    pub fn new(
151        http: Arc<dyn HttpClient>,
152        clock: Arc<dyn Clock>,
153        keys: VapidKeys,
154    ) -> Result<Self, WebPushConfigError> {
155        Self::with_record_size(http, clock, keys, ece::DEFAULT_RECORD_SIZE)
156    }
157
158    /// The same, with an explicit RFC 8188 record size.
159    ///
160    /// The default is derived so the whole body fits the 4096 octets a push
161    /// service is required to accept ([`ece::DEFAULT_RECORD_SIZE`]); raising
162    /// it is a deliberate bet that a venture's subscribers are all on
163    /// services that accept more.
164    ///
165    /// # Errors
166    ///
167    /// [`WebPushConfigError::Vapid`] as [`WebPush::new`];
168    /// [`WebPushConfigError::Ece`] if the record size is below RFC 8188's
169    /// floor of 18.
170    pub fn with_record_size(
171        http: Arc<dyn HttpClient>,
172        clock: Arc<dyn Clock>,
173        keys: VapidKeys,
174        record_size: u32,
175    ) -> Result<Self, WebPushConfigError> {
176        let vapid = Vapid::new(keys)?;
177        let ece = Ece::new(record_size)?;
178        Ok(Self {
179            inner: Inner::Live(Box::new(Live {
180                http,
181                clock,
182                vapid,
183                ece,
184            })),
185        })
186    }
187
188    /// A degraded adapter that reports [`PushOutcome::NotConfigured`]
189    /// without any network call — for a venture with no VAPID key set.
190    #[must_use]
191    pub fn not_configured() -> Self {
192        Self {
193            inner: Inner::NotConfigured,
194        }
195    }
196
197    /// The `applicationServerKey` a browser must pass to
198    /// `pushManager.subscribe()`, base64url — or `None` when the adapter is
199    /// not configured. A venture serves this to its client.
200    #[must_use]
201    pub fn public_key(&self) -> Option<&str> {
202        match &self.inner {
203            Inner::Live(live) => Some(live.vapid.public_key()),
204            Inner::NotConfigured => None,
205        }
206    }
207
208    /// The largest JSON payload this adapter will send, in bytes — computed
209    /// from the record size, see [`ece::max_plaintext`].
210    #[must_use]
211    pub fn max_payload(&self) -> Option<usize> {
212        match &self.inner {
213            Inner::Live(live) => Some(live.ece.max_plaintext()),
214            Inner::NotConfigured => None,
215        }
216    }
217}
218
219impl std::fmt::Debug for WebPush {
220    /// Prints whether it is configured and the public half of the VAPID key
221    /// — never the private key, which `Es256Signer` already refuses to
222    /// print, and never a recipient.
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        match &self.inner {
225            Inner::Live(live) => f
226                .debug_struct("WebPush")
227                .field("vapid", &live.vapid)
228                .field("record_size", &live.ece.record_size())
229                .finish_non_exhaustive(),
230            Inner::NotConfigured => f.write_str("WebPush::NotConfigured"),
231        }
232    }
233}
234
235/// The `Urgency` header (RFC 8030 §5.3) for a [`Priority`].
236///
237/// The header's four values are `very-low`, `low`, `normal` and `high`; the
238/// port carries two, so two are used. `Immediate` is `high` (wake the device
239/// now) and `Conserve` is `normal` — deliberately not `low`, which on a
240/// battery-saving device can mean "hold this until the screen is on next",
241/// and the port's `Conserve` only asks not to *wake* the device.
242fn urgency_header(priority: Priority) -> &'static str {
243    match priority {
244        Priority::Immediate => "high",
245        Priority::Conserve => "normal",
246    }
247}
248
249/// The `TTL` header: whole seconds, mandatory, [`DEFAULT_TTL`] when the
250/// caller named none.
251///
252/// A sub-second TTL rounds **up** to one second through
253/// [`ttl_secs`](cratefield_core::ttl_secs) rather than truncating to `0`,
254/// which would say the opposite thing — drop it unless the device is online
255/// right now.
256fn ttl_header(ttl: Option<Duration>) -> String {
257    ttl_secs(ttl.unwrap_or(DEFAULT_TTL)).to_string()
258}
259
260/// The `Topic` header for a `collapse_id`.
261///
262/// RFC 8030 §5.4 allows at most 32 characters from the URL-safe base64
263/// alphabet, which a caller's `collapse_id` need not respect — the same
264/// field is `apns-collapse-id` (64 bytes, any octets) and
265/// `android.collapse_key` (arbitrary) elsewhere. A value that does not fit
266/// is replaced by the first 32 base64url characters of its SHA-256, which
267/// keeps the one property a topic has to have: two notifications collapse
268/// exactly when their `collapse_id`s are equal.
269///
270/// The hash is not reversible and the push service never needed to read the
271/// value — a `Topic` is an opaque equality token, and RFC 8030 §5.4 warns
272/// that it is visible to the push service, so hashing a topic that might
273/// carry a user identifier is an improvement rather than a cost.
274fn topic_header(collapse_id: &str) -> String {
275    let fits = !collapse_id.is_empty()
276        && collapse_id.len() <= MAX_TOPIC_LEN
277        && collapse_id
278            .bytes()
279            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
280    if fits {
281        return collapse_id.to_owned();
282    }
283    let digest = Sha256::digest(collapse_id.as_bytes());
284    let mut encoded = URL_SAFE_NO_PAD.encode(digest);
285    encoded.truncate(MAX_TOPIC_LEN);
286    encoded
287}
288
289/// The JSON a service worker reads out of `event.data.json()`.
290///
291/// `Notification` is transport-neutral, so some fields are mapped and some
292/// are dropped, deliberately:
293///
294/// - `badge` is **dropped**: the port's `badge` is the iOS app-icon *count*,
295///   while the web `Notification.badge` is an icon *URL*. Putting a number
296///   there would be silently wrong.
297/// - `category` and `loc` are **dropped**: neither has a counterpart in the
298///   web Notification API — there is no OS-side string catalogue to look a
299///   loc key up in, so localisation on the web happens before this point.
300/// - `collapse_id` is the `Topic` **header**, not a payload field.
301/// - `data` is **nested** under `data` rather than merged at the top level
302///   (as the APNs adapter does), because `showNotification` takes a `data`
303///   member of its own and the service worker reads it there. Nesting also
304///   means a caller's key can never collide with the adapter's.
305fn build_payload(notification: &Notification) -> Vec<u8> {
306    let mut root = Map::new();
307    root.insert(
308        "title".to_owned(),
309        Value::String(notification.title.clone()),
310    );
311    root.insert("body".to_owned(), Value::String(notification.body.clone()));
312    if let Some(icon) = &notification.icon {
313        root.insert("icon".to_owned(), Value::String(icon.clone()));
314    }
315    if let Some(url) = &notification.url {
316        root.insert("url".to_owned(), Value::String(url.clone()));
317    }
318    // `tag` is the web Notification API's name for what the port calls
319    // `thread_id`: notifications sharing one replace each other in the
320    // shade. (`Topic`, from `collapse_id`, is the same idea one layer down —
321    // it collapses messages the *push service* has not delivered yet.)
322    if let Some(thread_id) = &notification.thread_id {
323        root.insert("tag".to_owned(), Value::String(thread_id.clone()));
324    }
325    root.insert("silent".to_owned(), Value::Bool(notification.silent));
326    if !notification.data.is_null() {
327        root.insert("data".to_owned(), notification.data.clone());
328    }
329    Value::Object(root).to_string().into_bytes()
330}
331
332/// What a redacted URL or path is replaced by in an error message.
333const REDACTED: &str = "[redacted]";
334
335/// How much of a push service's error body reaches the error message.
336const DETAIL_MAX_CHARS: usize = 200;
337
338/// The characters RFC 3986 allows in a URI reference — unreserved, reserved
339/// and the percent sign. A maximal run of them containing a `/` is treated
340/// as a URL or a path by [`redact_uris`].
341fn is_uri_char(c: char) -> bool {
342    c.is_ascii_alphanumeric() || "-._~:/?#[]@!$&'()*+,;=%".contains(c)
343}
344
345/// Replaces anything URL- or path-shaped with [`REDACTED`].
346///
347/// The rule is deliberately blunt — any run of URI characters containing a
348/// `/` goes — because the thing being protected is a bearer capability and
349/// the thing being preserved is a diagnostic string. Over-redacting an error
350/// body costs a line of log detail; under-redacting one publishes a
351/// subscription that anyone holding it can push to.
352fn redact_uris(text: &str) -> String {
353    let mut out = String::with_capacity(text.len());
354    let mut rest = text;
355    while !rest.is_empty() {
356        let start = rest.find(is_uri_char).unwrap_or(rest.len());
357        out.push_str(&rest[..start]);
358        rest = &rest[start..];
359        let end = rest.find(|c: char| !is_uri_char(c)).unwrap_or(rest.len());
360        let (run, tail) = rest.split_at(end);
361        if run.contains('/') {
362            out.push_str(REDACTED);
363        } else {
364            out.push_str(run);
365        }
366        rest = tail;
367    }
368    out
369}
370
371/// A push service's error body, made safe to put in an error message: the
372/// first [`DETAIL_MAX_CHARS`] characters, control characters dropped, and
373/// every URL or path redacted.
374///
375/// Push services answer with anything from problem+json to a bare string, so
376/// the body is passed through as text rather than parsed — the way the APNs
377/// adapter reads only its `reason` is not available here, because there is no
378/// error schema the vendors share.
379///
380/// **The redaction is the point.** ntfy, nginx and every CDN error page echo
381/// the request path, and a Web Push request path *is* the subscription:
382/// `crates/core/src/ports/push.rs` calls the endpoint a bearer capability —
383/// whoever holds it can push to that browser — that must appear "never in a
384/// log, an event payload, or an error body". This message is all three.
385///
386/// Bounded first and redacted second, so a 10 MB error page never becomes a
387/// 10 MB allocation. Truncation can only cut a URL's tail, never its head,
388/// so what survives the cut still carries the `/` that gets it redacted.
389fn detail(body: &[u8], status: StatusCode) -> String {
390    let text: String = String::from_utf8_lossy(body)
391        .chars()
392        .filter(|c| !c.is_control())
393        .take(DETAIL_MAX_CHARS)
394        .collect();
395    let text = redact_uris(&text);
396    let text = text.trim();
397    if text.is_empty() {
398        status.as_u16().to_string()
399    } else {
400        text.to_owned()
401    }
402}
403
404#[async_trait]
405impl Push for WebPush {
406    async fn send(
407        &self,
408        to: &Recipient,
409        notification: &Notification,
410    ) -> Result<PushOutcome, PushError> {
411        // One transport per adapter, decided before configuration is: an
412        // unconfigured Web Push adapter still does not serve APNs, and
413        // answering `NotConfigured` for an APNs recipient would claim a
414        // transport it will never carry (ADR 0015).
415        let Recipient::WebPush {
416            endpoint,
417            p256dh,
418            auth,
419        } = to
420        else {
421            return Err(PushError::unsupported_recipient(to));
422        };
423        let live = match &self.inner {
424            Inner::NotConfigured => return Ok(PushOutcome::NotConfigured),
425            Inner::Live(live) => live,
426        };
427
428        // Everything that can be refused locally is refused before a socket
429        // is opened: a bad endpoint, a malformed subscription, and an
430        // oversize payload are all the caller's to fix, and a push service
431        // would only tell us the same thing more slowly.
432        let origin =
433            vapid::origin_of(endpoint).map_err(|err| PushError::Rejected(err.to_string()))?;
434        let keys = SubscriptionKeys::parse(p256dh, auth)
435            .map_err(|err| PushError::Rejected(err.to_string()))?;
436        let payload = build_payload(notification);
437        let body = live.ece.seal(&keys, &payload).map_err(|err| match &err {
438            // A randomness failure is the platform's, not the request's:
439            // the same send may work on the next isolate.
440            EceError::Random(_) => PushError::transient(err.to_string()),
441            _ => PushError::Rejected(err.to_string()),
442        })?;
443
444        let mut builder = Request::builder()
445            .method("POST")
446            .uri(endpoint)
447            .header(
448                AUTHORIZATION,
449                live.vapid.authorization(live.clock.as_ref(), &origin),
450            )
451            // RFC 8291 §4: exactly one content encoding, and it is this one.
452            .header(CONTENT_ENCODING, "aes128gcm")
453            // RFC 8188 §3.1's own advice: an opaque type, so the request
454            // says nothing about what was encrypted.
455            .header(CONTENT_TYPE, "application/octet-stream")
456            .header("ttl", ttl_header(notification.ttl))
457            .header("urgency", urgency_header(notification.priority));
458        if let Some(collapse_id) = &notification.collapse_id {
459            builder = builder.header("topic", topic_header(collapse_id));
460        }
461        let request = builder
462            .body(Bytes::from(body))
463            .map_err(|err| PushError::Rejected(format!("could not build request: {err}")))?;
464
465        let response = live
466            .http
467            .send(request)
468            .await
469            .map_err(|err| PushError::transient(err.to_string()))?;
470
471        let status = response.status();
472        // Every arm says the same thing: `web push <status>: <redacted
473        // body>`. Built once so no arm can forget the redaction.
474        let message = || {
475            format!(
476                "web push {}: {}",
477                status.as_u16(),
478                detail(response.body(), status)
479            )
480        };
481        let retry_after = || cratefield_core::retry_after(response.headers(), live.clock.as_ref());
482
483        match status {
484            // RFC 8030 §5 answers `201 Created` with a `Location` naming the
485            // push message resource. Any other 2xx is accepted too: ntfy
486            // answers `200 OK` to a UnifiedPush publish, and refusing that
487            // would fail a delivery that succeeded.
488            status if status.is_success() => Ok(PushOutcome::Delivered {
489                id: response
490                    .headers()
491                    .get(LOCATION)
492                    .and_then(|value| value.to_str().ok())
493                    .map(str::to_owned),
494            }),
495            // The subscription is gone, and **only** this status says so.
496            // See the note on over-applying `Unregistered` in the module
497            // documentation before adding a second one.
498            StatusCode::GONE => Err(PushError::Unregistered),
499            // `404` is *not* that. RFC 8030 defines only `410`, and a `404`
500            // is what a proxy that came back without its routes, an edited
501            // ingress rule or a moved reverse proxy answers for **every**
502            // path — so pruning on it deletes a venture's whole Web Push
503            // register in one pass. That is unrecoverable server-side: a
504            // subscription can only be recreated by the browser calling
505            // `pushManager.subscribe()` again, which needs the user back on
506            // the site.
507            //
508            // So it is retryable, and the cost of being wrong is the other
509            // way round: a subscription that really is gone behind a service
510            // that only ever says `404` is retried until the caller's own
511            // attempt budget gives up, and lingers in the register. Wasted
512            // sends against lost subscribers is the trade.
513            StatusCode::NOT_FOUND => Err(PushError::transient_after(
514                format!(
515                    "{} (a 404 is not a dead subscription; only 410 is)",
516                    message()
517                ),
518                retry_after(),
519            )),
520            // ntfy answers `507 "cannot publish to UnifiedPush topic without
521            // previously active subscriber"` when it runs with
522            // `visitor-subscriber-rate-limiting` on, as the public
523            // `ntfy.sh` does. It is an operator configuration state, not
524            // load: it never clears on its own, so retrying it as a 5xx
525            // retries forever. The README documents it; this arm is the
526            // README made executable.
527            StatusCode::INSUFFICIENT_STORAGE => Err(PushError::Rejected(format!(
528                "{} (a push service refusing storage is an operator state, not load: on ntfy \
529                 this is `visitor-subscriber-rate-limiting`, which needs a subscriber on the \
530                 topic or the setting turned off)",
531                message()
532            ))),
533            // The VAPID token was refused. The cached token for this origin
534            // is dropped so the next send re-signs — and the send is
535            // **retryable**, because the commonest cause is exactly that:
536            // a clock a few minutes out, or a token that aged past its
537            // `exp` in flight, both of which the re-sign fixes. Returning
538            // `Rejected` here arranged the re-sign and then threw away the
539            // message that would have used it.
540            //
541            // A genuinely wrong `aud` or a revoked key repeats the error
542            // instead of clearing, and is bounded by the caller's retry
543            // budget rather than by us. The APNs adapter makes the same
544            // trade for `ExpiredProviderToken`/`InvalidProviderToken`.
545            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
546                live.vapid.invalidate(&origin);
547                Err(PushError::transient_after(
548                    format!(
549                        "{} (VAPID token refused; re-signing for this origin)",
550                        message()
551                    ),
552                    retry_after(),
553                ))
554            }
555            // A push service that moved. This adapter does not follow the
556            // redirect — the VAPID token is signed over the *old* origin, so
557            // replaying the POST at the `Location` earns a `401` from any
558            // service that checks `aud` — but a move is at worst temporary
559            // and never a reason to destroy the notification. The `Location`
560            // is deliberately not quoted: it is a push endpoint.
561            status if status.is_redirection() => Err(PushError::transient_after(
562                format!(
563                    "{message} (redirects are not followed: the VAPID `aud` is signed over the original origin)",
564                    message = message()
565                ),
566                retry_after(),
567            )),
568            StatusCode::TOO_MANY_REQUESTS => {
569                Err(PushError::transient_after(message(), retry_after()))
570            }
571            status if status.is_server_error() => {
572                Err(PushError::transient_after(message(), retry_after()))
573            }
574            // `400` (malformed request) and `413` (body too large for this
575            // service) are ours to fix, not to retry.
576            _ => Err(PushError::Rejected(message())),
577        }
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn a_topic_that_already_fits_is_passed_through() {
587        assert_eq!(topic_header("room-42"), "room-42");
588        assert_eq!(topic_header("a"), "a");
589        let exactly_32 = "a".repeat(MAX_TOPIC_LEN);
590        assert_eq!(topic_header(&exactly_32), exactly_32);
591    }
592
593    #[test]
594    fn a_topic_that_does_not_fit_becomes_a_stable_32_character_hash() {
595        let long = "a".repeat(MAX_TOPIC_LEN + 1);
596        let hashed = topic_header(&long);
597        assert_eq!(hashed.len(), MAX_TOPIC_LEN);
598        assert!(
599            hashed
600                .bytes()
601                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
602            "{hashed}"
603        );
604        // The one property a topic must keep: equal ids collapse, different
605        // ids do not.
606        assert_eq!(hashed, topic_header(&long));
607        assert_ne!(hashed, topic_header(&"b".repeat(MAX_TOPIC_LEN + 1)));
608
609        // Characters outside the alphabet are hashed even when short, and
610        // the empty string is hashed rather than sent as an illegal header.
611        for id in ["room/42", "user@example.test", "café", ""] {
612            let topic = topic_header(id);
613            assert_eq!(topic.len(), MAX_TOPIC_LEN, "{id}");
614            assert!(
615                topic
616                    .bytes()
617                    .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
618                "{id} -> {topic}"
619            );
620        }
621    }
622
623    #[test]
624    fn the_ttl_header_is_whole_seconds_with_a_day_by_default() {
625        assert_eq!(ttl_header(None), "86400");
626        assert_eq!(ttl_header(Some(Duration::from_secs(60))), "60");
627        assert_eq!(ttl_header(Some(Duration::ZERO)), "0", "deliberate drop");
628        assert_eq!(
629            ttl_header(Some(Duration::from_millis(900))),
630            "1",
631            "sub-second rounds up, never down to the drop-now instruction"
632        );
633    }
634
635    #[test]
636    fn urgency_maps_the_two_priorities_the_port_carries() {
637        assert_eq!(urgency_header(Priority::Immediate), "high");
638        assert_eq!(urgency_header(Priority::Conserve), "normal");
639    }
640
641    #[test]
642    fn an_error_detail_is_bounded_and_stripped_of_control_characters() {
643        assert_eq!(
644            detail(b"bad request", StatusCode::BAD_REQUEST),
645            "bad request"
646        );
647        assert_eq!(detail(b"", StatusCode::BAD_REQUEST), "400");
648        assert_eq!(detail(b"  \n ", StatusCode::GONE), "410");
649        assert_eq!(detail(b"a\nb\tc", StatusCode::BAD_REQUEST), "abc");
650        let long = vec![b'x'; 4_096];
651        assert_eq!(detail(&long, StatusCode::BAD_REQUEST).len(), 200);
652    }
653
654    /// The subscription endpoint is a bearer capability, and error pages
655    /// echo the request path. Everything URL-shaped goes; the prose around
656    /// it, which is the diagnostic value, stays.
657    #[test]
658    fn an_error_detail_never_carries_a_url_or_a_path() {
659        // Apache's 404 page, and the one that started this: the path *is*
660        // the subscription.
661        let apache =
662            b"The requested URL /wpush/v2/gAAAAABsecret-capability was not found on this server.";
663        let redacted = detail(apache, StatusCode::NOT_FOUND);
664        assert_eq!(
665            redacted,
666            "The requested URL [redacted] was not found on this server."
667        );
668        assert!(!redacted.contains("wpush"), "{redacted}");
669        assert!(!redacted.contains("gAAAAAB"), "{redacted}");
670
671        // An absolute URL anywhere in the body, including one glued to the
672        // text by a stripped newline.
673        for body in [
674            &b"see https://updates.push.services.mozilla.com/wpush/v2/gAAAAABsecret"[..],
675            &b"Not Found\nhttps://push.example/wpush/v2/gAAAAABsecret"[..],
676            &b"{\"endpoint\":\"https://push.example/wpush/v2/gAAAAABsecret\"}"[..],
677        ] {
678            let redacted = detail(body, StatusCode::NOT_FOUND);
679            assert!(!redacted.contains("gAAAAABsecret"), "{redacted}");
680            assert!(!redacted.contains("push.example"), "{redacted}");
681            assert!(!redacted.contains("mozilla.com"), "{redacted}");
682            assert!(redacted.contains(REDACTED), "{redacted}");
683        }
684
685        // The diagnostic that matters is prose and survives intact — this is
686        // the ntfy body the README quotes.
687        assert_eq!(
688            detail(
689                b"cannot publish to UnifiedPush topic without previously active subscriber",
690                StatusCode::INSUFFICIENT_STORAGE
691            ),
692            "cannot publish to UnifiedPush topic without previously active subscriber"
693        );
694        assert_eq!(
695            detail(b"UnauthorizedRegistration", StatusCode::UNAUTHORIZED),
696            "UnauthorizedRegistration"
697        );
698
699        // A body that is nothing but the endpoint redacts to the endpoint's
700        // absence, not to a bare status — and one long enough to be
701        // truncated is still cut tail-first, so the `/` that triggers the
702        // redaction is always inside what survives.
703        let long_endpoint = format!("https://push.example/wpush/v2/{}", "A".repeat(4_000));
704        let redacted = detail(long_endpoint.as_bytes(), StatusCode::NOT_FOUND);
705        assert_eq!(redacted, REDACTED);
706    }
707
708    #[test]
709    fn redaction_leaves_text_that_is_not_a_path_alone() {
710        assert_eq!(
711            redact_uris("plain prose, with punctuation!"),
712            "plain prose, with punctuation!"
713        );
714        assert_eq!(redact_uris(""), "");
715        assert_eq!(redact_uris("410"), "410");
716        // Non-ASCII is not a URI character, so it neither joins a run nor
717        // splits one incorrectly.
718        assert_eq!(redact_uris("café /a/b naïve"), "café [redacted] naïve");
719    }
720}