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) = ¬ification.icon {
313 root.insert("icon".to_owned(), Value::String(icon.clone()));
314 }
315 if let Some(url) = ¬ification.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) = ¬ification.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) = ¬ification.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}