1use std::fmt;
56use std::fmt::Write as _;
57use std::fs::OpenOptions;
58use std::io::Write as _;
59use std::path::{Path, PathBuf};
60use std::sync::Arc;
61use std::time::Duration;
62
63use base64::Engine as _;
64use base64::engine::general_purpose::URL_SAFE_NO_PAD;
65use cratefield_adapter_webpush::ece::{AUTH_SECRET_LEN, PUBLIC_KEY_LEN, SubscriptionKeys};
66use cratefield_adapter_webpush::vapid::origin_of;
67use cratefield_core::{
68 Clock, Config, HttpClient, Notification, Platform, Priority, PushError, PushOutcome, Recipient,
69};
70use cratefield_push_auth::Es256Signer;
71use cratefield_push_wiring::{
72 PushKey, PushVar, TransportWiring, build_push, inspect_push, transport_key, transport_name,
73 vars_for,
74};
75use serde::Deserialize;
76use zeroize::Zeroizing;
77
78const SCALAR_ATTEMPTS: usize = 8;
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
85pub enum Transport {
86 Apns,
88 Fcm,
90 WebPush,
93}
94
95impl Transport {
96 pub fn platform(self) -> Platform {
98 match self {
99 Transport::Apns => Platform::Ios,
100 Transport::Fcm => Platform::Android,
101 Transport::WebPush => Platform::Web,
102 }
103 }
104}
105
106#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, clap::ValueEnum)]
108pub enum PriorityArg {
109 #[default]
111 Immediate,
112 Conserve,
114}
115
116impl PriorityArg {
117 fn priority(self) -> Priority {
118 match self {
119 PriorityArg::Immediate => Priority::Immediate,
120 PriorityArg::Conserve => Priority::Conserve,
121 }
122 }
123}
124
125pub struct KeygenOptions<'a> {
134 pub file: Option<&'a Path>,
136 pub force: bool,
139 pub print_private: bool,
141}
142
143pub struct VapidKeygen {
150 public_key: String,
151 private_key: Zeroizing<String>,
152 written_to: Option<PathBuf>,
153 rotated: bool,
154}
155
156impl fmt::Debug for VapidKeygen {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 f.debug_struct("VapidKeygen")
161 .field("public_key", &self.public_key)
162 .field("private_key", &"[redacted]")
163 .field("written_to", &self.written_to)
164 .field("rotated", &self.rotated)
165 .finish()
166 }
167}
168
169impl VapidKeygen {
170 pub fn public_key(&self) -> &str {
174 &self.public_key
175 }
176
177 pub fn rotated(&self) -> bool {
180 self.rotated
181 }
182
183 pub fn warning(&self) -> Option<String> {
190 self.rotated.then(rotation_warning)
191 }
192
193 pub fn render(&self) -> String {
196 let mut out = String::new();
197 out.push_str("VAPID key pair generated.\n\n");
198 let _ = writeln!(
199 out,
200 " public key (base64url, the 65-byte uncompressed P-256 point a browser\n \
201 passes to pushManager.subscribe() as applicationServerKey):\n\n {}\n",
202 self.public_key
203 );
204 if let Some(path) = &self.written_to {
205 let _ = writeln!(
206 out,
207 " private key written to {} (owner-readable only)\n",
208 path.display()
209 );
210 }
211 let private = PushKey::VapidPrivateKey.name();
212 let subject = PushKey::VapidSubject.name();
213 let _ = write!(
214 out,
215 "Next steps:\n \
216 1. Configure the private key as {private} and a `mailto:` or `https:` contact\n \
217 URI as {subject} (docs/PUSH-ENV.md). On Workers:\n\n \
218 wrangler secret put {private}\n\n \
219 2. Run `fz doctor` — it reports whether this venture routes Web Push, and\n \
220 fails a production deploy that half-wires it.\n \
221 3. Do not write the public key down anywhere it is configured: it is derived\n \
222 from the private key on every boot, so the two cannot drift.\n"
223 );
224 out
225 }
226
227 pub fn private_key_disclosure(&self) -> Zeroizing<String> {
234 Zeroizing::new(format!(
235 " private key (base64url, the 32-byte P-256 scalar) — this is a secret;\n \
236 it is on your screen and in this shell's scrollback:\n\n {}\n",
237 self.private_key.as_str()
238 ))
239 }
240}
241
242pub fn vapid_keygen(options: &KeygenOptions<'_>) -> Result<VapidKeygen, String> {
252 if options.file.is_none() && !options.print_private {
253 return Err(format!(
254 "nothing would keep the private key. Pass --file <PATH> to write it \
255 (owner-readable only), or --print-private to print it. The public key alone is \
256 useless: it is derived from the private one, and a {} nobody has cannot be \
257 configured.",
258 PushKey::VapidPrivateKey.name()
259 ));
260 }
261 let (signer, scalar) = generate_key()?;
262 let private_key = Zeroizing::new(URL_SAFE_NO_PAD.encode(scalar.as_slice()));
266 let public_key = URL_SAFE_NO_PAD.encode(signer.public_key_uncompressed());
267 let rotated = match options.file {
268 Some(path) => write_private_key(path, &private_key, options.force)?,
269 None => false,
270 };
271 Ok(VapidKeygen {
272 public_key,
273 private_key,
274 written_to: options.file.map(Path::to_path_buf),
275 rotated,
276 })
277}
278
279fn generate_key() -> Result<(Es256Signer, Zeroizing<[u8; 32]>), String> {
291 let mut scalar = Zeroizing::new([0u8; 32]);
292 for _ in 0..SCALAR_ATTEMPTS {
293 getrandom::fill(scalar.as_mut_slice())
294 .map_err(|err| format!("could not draw random bytes for a VAPID key: {err}"))?;
295 if let Ok(signer) = Es256Signer::from_scalar(&scalar) {
296 return Ok((signer, scalar));
297 }
298 }
299 Err(format!(
300 "no valid P-256 scalar in {SCALAR_ATTEMPTS} draws — this platform's randomness is broken"
301 ))
302}
303
304fn write_private_key(path: &Path, key: &str, force: bool) -> Result<bool, String> {
329 let existed = path.exists();
330 if existed && !force {
331 return Err(rotation_refusal(path));
332 }
333 let temp = temp_sibling(path);
334 write_new_key_file(&temp, key).map_err(|err| {
335 let _ = std::fs::remove_file(&temp);
336 format!(
337 "cannot write the private key beside {}: {err}. The existing key, if any, is \
338 untouched.",
339 path.display()
340 )
341 })?;
342
343 if !existed {
344 if let Err(err) = new_file_options().open(path) {
349 let _ = std::fs::remove_file(&temp);
350 return Err(if err.kind() == std::io::ErrorKind::AlreadyExists {
351 rotation_refusal(path)
352 } else {
353 format!("cannot write the private key to {}: {err}", path.display())
354 });
355 }
356 }
357 std::fs::rename(&temp, path).map_err(|err| {
358 let _ = std::fs::remove_file(&temp);
359 format!(
360 "cannot move the new private key into {}: {err}. The existing key, if any, is \
361 untouched.",
362 path.display()
363 )
364 })?;
365 if let Some(parent) = path
369 .parent()
370 .filter(|parent| !parent.as_os_str().is_empty())
371 && let Ok(dir) = std::fs::File::open(parent)
372 {
373 let _ = dir.sync_all();
374 }
375 Ok(existed)
376}
377
378fn temp_sibling(path: &Path) -> PathBuf {
382 let name = path.file_name().map_or_else(
383 || "vapid.key".to_owned(),
384 |name| name.to_string_lossy().into_owned(),
385 );
386 let unique = std::time::SystemTime::now()
387 .duration_since(std::time::UNIX_EPOCH)
388 .map_or(0, |since| since.as_nanos());
389 path.with_file_name(format!(".{name}.{}.{unique}.tmp", std::process::id()))
390}
391
392fn new_file_options() -> OpenOptions {
396 let mut options = OpenOptions::new();
397 options.write(true).create_new(true);
398 #[cfg(unix)]
399 {
400 use std::os::unix::fs::OpenOptionsExt as _;
401 options.mode(0o600);
402 }
403 options
404}
405
406fn write_new_key_file(path: &Path, key: &str) -> std::io::Result<()> {
410 let mut file = new_file_options().open(path)?;
411 writeln!(file, "{key}")?;
412 file.sync_all()
413}
414
415fn rotation_refusal(path: &Path) -> String {
417 format!(
418 "{} already exists, and overwriting it would rotate this venture's VAPID key. {} Pass \
419 --force if that is what you mean, or write the new key somewhere else.",
420 path.display(),
421 ROTATION_COST
422 )
423}
424
425fn rotation_warning() -> String {
427 format!("warning: the VAPID key was rotated. {ROTATION_COST}\n")
428}
429
430const ROTATION_COST: &str = "A subscription is bound to the application server key it was \
433 created with, so rotating invalidates every existing browser \
434 subscription — and no server can recreate one: each browser has to \
435 call pushManager.subscribe() again, which needs the user back on \
436 the site with notification permission still granted.";
437
438pub struct SendArgs {
443 pub transport: Transport,
445 pub recipient: String,
448 pub title: String,
450 pub body: String,
452 pub data: Option<String>,
454 pub url: Option<String>,
456 pub ttl: Option<u64>,
458 pub priority: PriorityArg,
460 pub silent: bool,
462}
463
464pub struct SendRequest {
466 recipient: Recipient,
467 notification: Notification,
468}
469
470impl SendRequest {
471 pub fn from_args(args: &SendArgs) -> Result<Self, String> {
479 let recipient = parse_recipient(args.transport, &args.recipient)?;
480 let data = match &args.data {
481 Some(raw) => {
482 let value: serde_json::Value = serde_json::from_str(raw)
483 .map_err(|err| format!("--data is not JSON: {err}. Pass a JSON object."))?;
484 if !value.is_object() {
493 return Err(format!(
494 "--data is {}, not a JSON object. Pass an object — \
495 `--data '{{\"key\":\"value\"}}'` — because a payload that is not one is \
496 silently dropped by APNs and FCM and forwarded by Web Push, so the same \
497 flag would mean three things.",
498 json_kind(&value)
499 ));
500 }
501 value
502 }
503 None => serde_json::Value::Null,
504 };
505 let notification = Notification {
506 data,
507 url: args.url.clone(),
508 ttl: args.ttl.map(Duration::from_secs),
509 priority: args.priority.priority(),
510 silent: args.silent,
511 ..Notification::new(args.title.clone(), args.body.clone())
512 };
513 Ok(Self {
514 recipient,
515 notification,
516 })
517 }
518
519 pub fn transport(&self) -> Platform {
523 self.recipient.platform()
524 }
525}
526
527pub async fn send(
535 config: &dyn Config,
536 http: &Arc<dyn HttpClient>,
537 clock: &Arc<dyn Clock>,
538 request: &SendRequest,
539) -> SendReport {
540 let (push, wiring) = build_push(config, http, clock);
541 let transport = request.transport();
542 let verdict = wiring.get(transport).clone();
543 if !verdict.is_routed() {
544 return SendReport::new(request, verdict, SendOutcome::Unrouted);
545 }
546 let outcome = push.send(&request.recipient, &request.notification).await;
547 SendReport::new(request, verdict, SendOutcome::Sent(outcome))
548}
549
550pub fn plan(config: &dyn Config, request: &SendRequest) -> SendReport {
554 let wiring = inspect_push(config);
555 let verdict = wiring.get(request.transport()).clone();
556 SendReport::new(request, verdict, SendOutcome::Planned)
557}
558
559#[cfg(feature = "push-send")]
580pub fn send_now(config: &dyn Config, request: &SendRequest) -> Result<SendReport, String> {
581 let (http, clock) = send_stack();
582 let runtime = tokio::runtime::Builder::new_current_thread()
583 .enable_all()
584 .build()
585 .map_err(|err| format!("cannot start the async runtime this send needs: {err}"))?;
586 Ok(runtime.block_on(send(config, &http, &clock, request)))
587}
588
589#[cfg(feature = "push-send")]
603fn send_stack() -> (Arc<dyn HttpClient>, Arc<dyn Clock>) {
604 let clock: Arc<dyn Clock> = Arc::new(cratefield_runtime_native::TokioClock);
605 let http: Arc<dyn HttpClient> = Arc::new(cratefield_core::BoundedHttpClient::new(
606 Arc::new(cratefield_runtime_native::ReqwestClient::new()),
607 Arc::clone(&clock),
608 ));
609 (http, clock)
610}
611
612#[cfg(not(feature = "push-send"))]
619pub fn send_now(_config: &dyn Config, _request: &SendRequest) -> Result<SendReport, String> {
620 Err(
621 "this `fz` was built without the `push-send` feature, so it has no HTTP client and \
629 cannot send. Install one that has it — `cargo install cratefield-cli --features \
630 push-send` — and run that binary (it needs no compiled-in harness); do not add the \
631 feature to the venture's own `cratefield-cli` dependency, which would pull the native \
632 runtime into its wasm build. Or run `fz push send --dry-run`, which reports the \
633 transport's wiring without sending."
634 .to_owned(),
635 )
636}
637
638enum SendOutcome {
640 Planned,
642 Unrouted,
644 Sent(Result<PushOutcome, PushError>),
646}
647
648pub struct SendReport {
650 transport: Platform,
651 recipient: String,
655 credentials: Vec<String>,
658 wiring: TransportWiring,
659 outcome: SendOutcome,
660}
661
662impl SendReport {
663 fn new(request: &SendRequest, wiring: TransportWiring, outcome: SendOutcome) -> Self {
664 Self {
665 transport: request.transport(),
666 recipient: format!("{:?}", request.recipient),
667 credentials: credential_parts(&request.recipient),
668 wiring,
669 outcome,
670 }
671 }
672
673 fn redacted(&self, text: &str) -> String {
683 let mut out = text.to_owned();
684 for part in &self.credentials {
685 if out.contains(part.as_str()) {
686 out = out.replace(part.as_str(), REDACTED);
687 }
688 }
689 out
690 }
691
692 pub fn ok(&self) -> bool {
696 match &self.outcome {
697 SendOutcome::Planned => self.wiring.is_routed(),
698 SendOutcome::Sent(Ok(PushOutcome::Delivered { .. })) => true,
699 SendOutcome::Unrouted | SendOutcome::Sent(_) => false,
702 }
703 }
704
705 pub fn render(&self) -> String {
708 let mut out = String::new();
709 let _ = writeln!(
710 out,
711 "transport: {} ({})",
712 transport_key(self.transport),
713 transport_name(self.transport)
714 );
715 let _ = writeln!(out, "recipient: {}", self.recipient);
716 let _ = writeln!(out, "wiring: {}", self.wiring.verdict());
717 out.push('\n');
718 out.push_str(&self.result());
719 out
720 }
721
722 fn result(&self) -> String {
723 match &self.outcome {
724 SendOutcome::Planned if self.wiring.is_routed() => format!(
725 "result: NOT SENT (--dry-run)\n {} is configured and would have carried this \
726 notification. Drop --dry-run to send it.\n",
727 transport_name(self.transport)
728 ),
729 SendOutcome::Sent(Ok(PushOutcome::Delivered { id })) => match id {
730 Some(id) => format!("result: DELIVERED\n the push service accepted it: {id}\n"),
731 None => "result: DELIVERED\n the push service accepted it (it named no id)\n"
732 .to_owned(),
733 },
734 SendOutcome::Sent(Err(err)) => format!(
742 "result: FAILED\n {}\n {}\n",
743 self.redacted(&err.to_string()),
744 advice(err, self.transport)
745 ),
746 SendOutcome::Planned
752 | SendOutcome::Unrouted
753 | SendOutcome::Sent(Ok(PushOutcome::NotConfigured)) => {
754 format!("result: NOT SENT\n {}\n", self.unrouted_reason())
755 }
756 }
757 }
758
759 fn unrouted_reason(&self) -> String {
762 let name = transport_name(self.transport);
763 match &self.wiring {
764 TransportWiring::Absent => format!(
765 "not one of {name}'s variables is set here ({}), so this venture does not wire \
766 {name} at all and nothing was sent. Set them (docs/PUSH-ENV.md), or send over a \
767 transport this venture configures.",
768 variable_names(self.transport)
769 ),
770 TransportWiring::Partial { present, missing } => format!(
771 "{name} is half-wired — {} set, {} not — so it is left unrouted and nothing was \
772 sent. `fz doctor` reports the same thing, and fails a production deploy on it.",
773 present.join(", "),
774 missing.join(", ")
775 ),
776 TransportWiring::Invalid { reason } => format!(
777 "{name}'s credentials were refused: {reason}. It is left unrouted, so nothing was \
778 sent and every deployed send would answer NotConfigured too."
779 ),
780 TransportWiring::Configured => format!(
784 "{name} is configured, but no adapter took the send. This is a \
785 cratefield-push-wiring bug: please file it."
786 ),
787 }
788 }
789}
790
791const REDACTED: &str = "[redacted]";
794
795const MIN_REDACTABLE: usize = 12;
802
803fn credential_parts(recipient: &Recipient) -> Vec<String> {
809 let mut parts = match recipient {
810 Recipient::Apns { device_token } => vec![device_token.clone()],
811 Recipient::Fcm { registration_token } => vec![registration_token.clone()],
812 Recipient::WebPush {
813 endpoint,
814 p256dh,
815 auth,
816 } => {
817 let mut parts = vec![endpoint.clone(), p256dh.clone(), auth.clone()];
818 if let Some(rest) = endpoint.split_once("://").map(|(_, rest)| rest)
822 && let Some(index) = rest.find('/')
823 {
824 parts.push(rest[index..].to_owned());
825 }
826 parts
827 }
828 };
829 parts.retain(|part| part.len() >= MIN_REDACTABLE);
830 parts.sort_by_key(|part| std::cmp::Reverse(part.len()));
831 parts
832}
833
834fn advice(err: &PushError, transport: Platform) -> &'static str {
837 match (err, transport) {
838 (PushError::Unregistered, Platform::Web) => {
843 "this recipient is gone: delete it from the venture's registry. A Web Push \
844 subscription cannot be recreated server-side — only the browser can, by \
845 subscribing again."
846 }
847 (PushError::Unregistered, Platform::Ios | Platform::Android) => {
848 "this recipient is gone: delete it from the venture's registry. A device token \
849 cannot be minted server-side — only the app on the device can get a new one, by \
850 registering for push again."
851 }
852 (PushError::Rejected(_), _) => {
853 "the provider refused the request; it will refuse it again unchanged. Check the \
854 recipient and this transport's credentials (`fz doctor`)."
855 }
856 (PushError::Transient { .. }, _) => {
857 "retryable: the provider was unavailable or asked for a pause. Nothing about the \
858 recipient or the credentials is proven wrong by this."
859 }
860 }
861}
862
863fn json_kind(value: &serde_json::Value) -> &'static str {
866 match value {
867 serde_json::Value::Null => "null",
868 serde_json::Value::Bool(_) => "a boolean",
869 serde_json::Value::Number(_) => "a number",
870 serde_json::Value::String(_) => "a string",
871 serde_json::Value::Array(_) => "an array",
872 serde_json::Value::Object(_) => "an object",
873 }
874}
875
876fn variable_names(transport: Platform) -> String {
886 vars_for(transport)
887 .filter(|var| var.required)
888 .map(PushVar::name)
889 .collect::<Vec<_>>()
890 .join(", ")
891}
892
893fn parse_recipient(transport: Transport, value: &str) -> Result<Recipient, String> {
895 let value = value.trim();
896 if value.is_empty() {
897 return Err("--recipient is empty".to_owned());
898 }
899 match transport {
900 Transport::WebPush => parse_subscription(value).map(Subscription::into_recipient),
901 Transport::Apns | Transport::Fcm if value.starts_with('{') => Err(format!(
902 "--recipient looks like a JSON subscription, but --transport is {}, which takes the \
903 bare token the device registered with. A subscription JSON goes with --transport \
904 web-push.",
905 transport_key(transport.platform())
906 )),
907 Transport::Apns => Ok(Recipient::apns(value)),
908 Transport::Fcm => Ok(Recipient::fcm(value)),
909 }
910}
911
912struct Subscription {
917 endpoint: String,
918 p256dh: String,
919 auth: String,
920}
921
922impl Subscription {
923 fn into_recipient(self) -> Recipient {
924 Recipient::web_push(self.endpoint, self.p256dh, self.auth)
925 }
926}
927
928#[derive(Deserialize)]
932struct SubscriptionJson {
933 endpoint: Option<String>,
934 keys: Option<SubscriptionKeysJson>,
935 p256dh: Option<String>,
936 auth: Option<String>,
937}
938
939#[derive(Deserialize)]
940struct SubscriptionKeysJson {
941 p256dh: Option<String>,
942 auth: Option<String>,
943}
944
945fn parse_subscription(json: &str) -> Result<Subscription, String> {
946 let parsed: SubscriptionJson = serde_json::from_str(json)
947 .map_err(|err| format!("the subscription is not JSON: {err}. {SUBSCRIPTION_SHAPE}"))?;
948 let endpoint = parsed.endpoint.ok_or_else(|| missing_field("endpoint"))?;
949 let (p256dh, auth) = match parsed.keys {
950 Some(keys) => (
951 keys.p256dh.ok_or_else(|| missing_field("keys.p256dh"))?,
952 keys.auth.ok_or_else(|| missing_field("keys.auth"))?,
953 ),
954 None => (
955 parsed.p256dh.ok_or_else(|| missing_field("p256dh"))?,
956 parsed.auth.ok_or_else(|| missing_field("auth"))?,
957 ),
958 };
959 Ok(Subscription {
960 endpoint,
961 p256dh,
962 auth,
963 })
964}
965
966fn missing_field(field: &str) -> String {
967 format!("the subscription JSON has no `{field}`. {SUBSCRIPTION_SHAPE}")
968}
969
970const SUBSCRIPTION_SHAPE: &str = "Pass what the browser hands over — \
971 JSON.stringify(await registration.pushManager.getSubscription()) \
972 — which is {\"endpoint\":\"…\",\"keys\":{\"p256dh\":\"…\",\
973 \"auth\":\"…\"}}. The flattened \
974 {\"endpoint\":…,\"p256dh\":…,\"auth\":…} is accepted too.";
975
976#[derive(Debug)]
981pub struct SubscriptionReport {
982 aud: String,
984}
985
986impl SubscriptionReport {
987 pub fn aud(&self) -> &str {
989 &self.aud
990 }
991
992 pub fn render(&self) -> String {
994 format!(
995 "subscription: valid\n\n \
996 aud: {aud}\n \
997 the audience the adapter signs into its VAPID token for this subscription:\n \
998 the push service's origin, and never the subscription's path. A path-bearing\n \
999 aud is the commonest cause of a VAPID 401; this one has none.\n \
1000 endpoint: {aud}/…\n \
1001 the path is withheld here: it is a bearer capability — whoever holds it\n \
1002 can push to this browser until the subscription dies.\n \
1003 p256dh: {PUBLIC_KEY_LEN} bytes, an uncompressed P-256 point on the curve\n \
1004 auth: {AUTH_SECRET_LEN} bytes\n",
1005 aud = self.aud
1006 )
1007 }
1008}
1009
1010pub fn inspect_subscription(json: &str) -> Result<SubscriptionReport, String> {
1023 let subscription = parse_subscription(json)?;
1024 let aud = origin_of(&subscription.endpoint).map_err(|err| {
1025 format!(
1026 "{err} — a Web Push endpoint is the absolute http(s) URL the browser put in \
1027 `subscription.endpoint`, and its origin is what the VAPID token claims."
1028 )
1029 })?;
1030 SubscriptionKeys::parse(&subscription.p256dh, &subscription.auth)
1031 .map_err(|err| format!("{err} — this subscription cannot be encrypted to as it stands."))?;
1032 Ok(SubscriptionReport { aud })
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use super::*;
1038 use cratefield_core::MapConfig;
1039 use cratefield_core::SystemClock;
1040 use cratefield_testing::vectors::{
1041 RFC8291_AUTH_SECRET as AUTH, RFC8291_UA_PUBLIC as P256DH, TEST_P256_PEM,
1042 TEST_VAPID_SUBJECT, WEB_PUSH_ENDPOINT as ENDPOINT, WEB_PUSH_ENDPOINT_CAPABILITY,
1043 web_push_subscription_json,
1044 };
1045 use cratefield_testing::{FakeHttpClient, TempDir};
1046
1047 fn subscription_json() -> String {
1048 web_push_subscription_json()
1049 }
1050
1051 fn send_args(transport: Transport, recipient: &str) -> SendArgs {
1052 SendArgs {
1053 transport,
1054 recipient: recipient.to_owned(),
1055 title: "title".to_owned(),
1056 body: "body".to_owned(),
1057 data: None,
1058 url: None,
1059 ttl: None,
1060 priority: PriorityArg::Immediate,
1061 silent: false,
1062 }
1063 }
1064
1065 #[test]
1066 fn a_generated_key_is_a_usable_p256_key_and_the_public_half_is_65_bytes() {
1067 let dir = TempDir::new("fz-push-keygen-usable");
1071 let path = dir.join("vapid.key");
1072 let generated = vapid_keygen(&KeygenOptions {
1073 file: Some(&path),
1074 force: false,
1075 print_private: false,
1076 })
1077 .expect("generates");
1078
1079 let public = URL_SAFE_NO_PAD
1080 .decode(generated.public_key())
1081 .expect("base64url");
1082 assert_eq!(public.len(), PUBLIC_KEY_LEN, "uncompressed P-256 point");
1083 assert_eq!(public[0], 0x04, "uncompressed point form");
1084
1085 let second = vapid_keygen(&KeygenOptions {
1088 file: Some(&dir.join("second.key")),
1089 force: false,
1090 print_private: false,
1091 })
1092 .expect("generates");
1093 assert_ne!(generated.public_key(), second.public_key());
1094 }
1095
1096 #[test]
1097 fn the_private_key_is_never_in_the_report_unless_it_is_asked_for() {
1098 let generated = vapid_keygen(&KeygenOptions {
1099 file: None,
1100 force: false,
1101 print_private: true,
1102 })
1103 .expect("generates");
1104 let private = generated.private_key.as_str().to_owned();
1105
1106 assert!(
1107 !generated.render().contains(&private),
1108 "the report must not carry the private key"
1109 );
1110 assert!(
1111 !format!("{generated:?}").contains(&private),
1112 "nor must Debug"
1113 );
1114 let disclosure: Zeroizing<String> = generated.private_key_disclosure();
1118 assert!(
1119 disclosure.contains(&private),
1120 "--print-private asks for it explicitly"
1121 );
1122 }
1123
1124 #[test]
1125 fn a_keygen_that_would_discard_the_private_key_is_refused() {
1126 let error = vapid_keygen(&KeygenOptions {
1127 file: None,
1128 force: false,
1129 print_private: false,
1130 })
1131 .expect_err("nothing would keep the key");
1132 assert!(error.contains("--file"), "{error}");
1133 assert!(error.contains("--print-private"), "{error}");
1134 assert!(error.contains(PushKey::VapidPrivateKey.name()), "{error}");
1136 }
1137
1138 #[test]
1139 fn an_existing_key_is_not_overwritten_without_force_and_the_warning_says_why() {
1140 let dir = TempDir::new("fz-push-keygen-force");
1141 let path = dir.join("vapid.key");
1142 let first = vapid_keygen(&KeygenOptions {
1143 file: Some(&path),
1144 force: false,
1145 print_private: false,
1146 })
1147 .expect("generates");
1148 let kept = std::fs::read_to_string(&path).expect("written");
1149
1150 let error = vapid_keygen(&KeygenOptions {
1151 file: Some(&path),
1152 force: false,
1153 print_private: false,
1154 })
1155 .expect_err("refuses to rotate");
1156 assert!(error.contains("--force"), "{error}");
1157 assert!(
1158 error.contains("invalidates every existing browser subscription"),
1159 "{error}"
1160 );
1161 assert_eq!(
1162 std::fs::read_to_string(&path).expect("still there"),
1163 kept,
1164 "the refused run left the key alone"
1165 );
1166
1167 let rotated = vapid_keygen(&KeygenOptions {
1168 file: Some(&path),
1169 force: true,
1170 print_private: false,
1171 })
1172 .expect("rotates with --force");
1173 assert!(rotated.rotated());
1174 let warning = rotated.warning().expect("a rotation warns");
1178 assert!(warning.contains("rotated"), "{warning}");
1179 assert!(
1180 warning.contains("invalidates every existing browser subscription"),
1181 "{warning}"
1182 );
1183 assert!(
1184 !rotated.render().contains("warning"),
1185 "the warning is stderr's, not stdout's: {}",
1186 rotated.render()
1187 );
1188 assert_ne!(rotated.public_key(), first.public_key());
1189 assert_ne!(std::fs::read_to_string(&path).expect("rewritten"), kept);
1190 }
1191
1192 #[test]
1201 #[cfg(unix)]
1202 fn a_force_rotation_that_cannot_complete_leaves_the_old_key_intact() {
1203 use std::os::unix::fs::PermissionsExt as _;
1204
1205 let dir = TempDir::new("fz-push-keygen-torn");
1206 let path = dir.join("vapid.key");
1207 vapid_keygen(&KeygenOptions {
1208 file: Some(&path),
1209 force: false,
1210 print_private: false,
1211 })
1212 .expect("the venture's key");
1213 let kept = std::fs::read_to_string(&path).expect("written");
1214
1215 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500))
1220 .expect("makes the directory read-only");
1221 let staged = std::fs::write(dir.join("probe"), "x").is_err();
1224 let outcome = if staged {
1225 Some(vapid_keygen(&KeygenOptions {
1226 file: Some(&path),
1227 force: true,
1228 print_private: false,
1229 }))
1230 } else {
1231 None
1232 };
1233 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))
1236 .expect("restores the directory");
1237
1238 let Some(outcome) = outcome else {
1239 eprintln!("skipped: this process writes into a read-only directory (root?)");
1240 return;
1241 };
1242 let Err(error) = outcome else {
1243 panic!("the rotation could not be completed, so it must have refused");
1244 };
1245 assert!(
1246 error.contains("untouched"),
1247 "the refusal says the key survived: {error}"
1248 );
1249 assert_eq!(
1250 std::fs::read_to_string(&path).expect("the old key is still there"),
1251 kept,
1252 "a rotation that could not be completed destroyed the venture's only key"
1253 );
1254 }
1255
1256 #[test]
1257 fn a_subscription_is_read_in_both_shapes_and_its_aud_is_the_origin() {
1258 let nested = inspect_subscription(&subscription_json()).expect("valid");
1259 let flat = inspect_subscription(&format!(
1260 "{{\"endpoint\":\"{ENDPOINT}\",\"p256dh\":\"{P256DH}\",\"auth\":\"{AUTH}\"}}"
1261 ))
1262 .expect("valid");
1263 assert_eq!(nested.aud(), flat.aud());
1264 assert_eq!(nested.aud(), "https://updates.push.services.mozilla.com");
1265 assert!(
1268 !nested.render().contains(WEB_PUSH_ENDPOINT_CAPABILITY),
1269 "{}",
1270 nested.render()
1271 );
1272 }
1273
1274 #[test]
1275 fn a_subscription_with_a_wrong_length_key_is_refused_by_name() {
1276 let short_p256dh = &P256DH[..P256DH.len() - 2];
1279 let error = inspect_subscription(&format!(
1280 "{{\"endpoint\":\"{ENDPOINT}\",\"keys\":{{\"p256dh\":\"{short_p256dh}\",\"auth\":\"{AUTH}\"}}}}"
1281 ))
1282 .expect_err("wrong length");
1283 assert!(error.contains("p256dh"), "{error}");
1284
1285 let error = inspect_subscription(&format!(
1286 "{{\"endpoint\":\"{ENDPOINT}\",\"keys\":{{\"p256dh\":\"{P256DH}\",\"auth\":\"c2hvcnQ\"}}}}"
1287 ))
1288 .expect_err("wrong length");
1289 assert!(error.contains("auth"), "{error}");
1290 }
1291
1292 #[test]
1293 fn an_endpoint_that_is_not_an_absolute_http_url_is_refused() {
1294 for endpoint in ["/relative/path", "ftp://push.example/x"] {
1295 let error = inspect_subscription(&format!(
1296 "{{\"endpoint\":\"{endpoint}\",\"keys\":{{\"p256dh\":\"{P256DH}\",\"auth\":\"{AUTH}\"}}}}"
1297 ))
1298 .expect_err("no origin");
1299 assert!(error.contains("endpoint"), "{endpoint}: {error}");
1300 }
1301 }
1302
1303 #[test]
1304 fn a_bare_token_and_a_subscription_are_not_interchangeable() {
1305 let Err(error) = SendRequest::from_args(&send_args(Transport::Apns, &subscription_json()))
1306 else {
1307 panic!("a subscription is not a device token");
1308 };
1309 assert!(error.contains("web-push"), "{error}");
1310
1311 let request = SendRequest::from_args(&send_args(Transport::Apns, " DEVICE-TOKEN "))
1312 .expect("a device token");
1313 assert_eq!(request.transport(), Platform::Ios);
1314
1315 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1316 .expect("a subscription");
1317 assert_eq!(request.transport(), Platform::Web);
1318 }
1319
1320 #[test]
1321 fn an_unrouted_transport_reports_why_nothing_was_sent() {
1322 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1325 .expect("parses");
1326 let report = plan(
1327 &MapConfig::from_pairs(Vec::<(String, String)>::new()),
1328 &request,
1329 );
1330 assert!(!report.ok());
1331 let rendered = report.render();
1332 assert!(rendered.contains("NOT SENT"), "{rendered}");
1333 assert!(
1334 rendered.contains(PushKey::VapidPrivateKey.name()),
1335 "{rendered}"
1336 );
1337 assert!(
1338 rendered.contains(PushKey::VapidSubject.name()),
1339 "{rendered}"
1340 );
1341
1342 let half = MapConfig::from_pairs([(
1344 PushKey::VapidSubject.name().to_owned(),
1345 "mailto:ops@example.test".to_owned(),
1346 )]);
1347 let rendered = plan(&half, &request).render();
1348 assert!(rendered.contains("half-wired"), "{rendered}");
1349 assert!(
1350 rendered.contains(PushKey::VapidPrivateKey.name()),
1351 "{rendered}"
1352 );
1353 }
1354
1355 #[test]
1356 fn a_dry_run_of_a_configured_transport_says_it_would_have_sent() {
1357 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1358 .expect("parses");
1359 let report = plan(&web_push_config(), &request);
1360 assert!(report.ok(), "{}", report.render());
1361 assert!(report.render().contains("--dry-run"), "{}", report.render());
1362 }
1363
1364 #[test]
1365 fn a_configured_transport_sends_and_reports_the_outcome() {
1366 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1367 .expect("parses");
1368 let http: Arc<dyn HttpClient> = Arc::new(FakeHttpClient::ok_json(""));
1369 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
1370 let report = pollster::block_on(send(&web_push_config(), &http, &clock, &request));
1371 assert!(report.ok(), "{}", report.render());
1372 assert!(report.render().contains("DELIVERED"), "{}", report.render());
1373 }
1374
1375 #[test]
1376 fn the_report_never_prints_a_value_out_of_the_environment() {
1377 let sentinel = |var: &PushVar| format!("SENTINEL-{}-VALUE", var.name());
1383 let config = MapConfig::from_pairs(
1384 cratefield_push_wiring::PUSH_ENV
1385 .iter()
1386 .map(|var| (var.name().to_owned(), sentinel(var))),
1387 );
1388 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1389 .expect("parses");
1390 let rendered = plan(&config, &request).render();
1391 for var in cratefield_push_wiring::PUSH_ENV {
1392 assert!(
1393 !rendered.contains(&sentinel(var)),
1394 "{} leaked into the report:\n{rendered}",
1395 var.name()
1396 );
1397 }
1398 assert!(
1400 !rendered.contains(AUTH),
1401 "the auth secret leaked:\n{rendered}"
1402 );
1403 assert!(
1404 !rendered.contains(WEB_PUSH_ENDPOINT_CAPABILITY),
1405 "the endpoint leaked:\n{rendered}"
1406 );
1407 }
1408
1409 #[test]
1423 fn the_failure_path_never_prints_the_recipient() {
1424 const DEVICE_TOKEN: &str =
1427 "0a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff";
1428
1429 let leaky = |target: &str| {
1430 let http: Arc<dyn HttpClient> = Arc::new(FakeHttpClient::scripted(vec![Err(
1431 cratefield_core::HttpError::Transport(format!(
1432 "error sending request for url ({target})"
1433 )),
1434 )]));
1435 http
1436 };
1437 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
1438
1439 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1440 .expect("parses");
1441 let report =
1442 pollster::block_on(send(&web_push_config(), &leaky(ENDPOINT), &clock, &request));
1443 let rendered = report.render();
1444 assert!(!report.ok(), "{rendered}");
1445 assert!(rendered.contains("FAILED"), "{rendered}");
1446 assert!(
1447 !rendered.contains(WEB_PUSH_ENDPOINT_CAPABILITY),
1448 "the subscription's bearer capability leaked through the failure:\n{rendered}"
1449 );
1450 assert!(
1451 !rendered.contains(ENDPOINT),
1452 "the endpoint leaked through the failure:\n{rendered}"
1453 );
1454 assert!(rendered.contains("retryable"), "{rendered}");
1456
1457 let request = SendRequest::from_args(&send_args(Transport::Apns, DEVICE_TOKEN))
1458 .expect("a device token");
1459 let report = pollster::block_on(send(
1460 &apns_config(),
1461 &leaky(&format!(
1462 "https://api.push.apple.com/3/device/{DEVICE_TOKEN}"
1463 )),
1464 &clock,
1465 &request,
1466 ));
1467 let rendered = report.render();
1468 assert!(rendered.contains("FAILED"), "{rendered}");
1469 assert!(
1470 !rendered.contains(DEVICE_TOKEN),
1471 "the device token leaked through the failure:\n{rendered}"
1472 );
1473 }
1474
1475 #[test]
1480 fn unregistered_advice_is_the_transports_own() {
1481 let web = advice(&PushError::Unregistered, Platform::Web);
1482 assert!(
1483 web.contains("delete it from the venture's registry"),
1484 "{web}"
1485 );
1486 assert!(web.contains("browser"), "{web}");
1487
1488 for platform in [Platform::Ios, Platform::Android] {
1489 let device = advice(&PushError::Unregistered, platform);
1490 assert!(
1491 device.contains("delete it from the venture's registry"),
1492 "{platform:?}: {device}"
1493 );
1494 assert!(!device.contains("browser"), "{platform:?}: {device}");
1495 assert!(!device.contains("Web Push"), "{platform:?}: {device}");
1496 }
1497 }
1498
1499 #[test]
1500 fn data_has_to_be_a_json_object() {
1501 for raw in ["[1,2]", "\"a string\"", "42", "null", "true"] {
1505 let mut args = send_args(Transport::WebPush, &subscription_json());
1506 args.data = Some(raw.to_owned());
1507 let Err(error) = SendRequest::from_args(&args) else {
1508 panic!("{raw} is not a JSON object");
1509 };
1510 assert!(error.contains("JSON object"), "{raw}: {error}");
1511 }
1512
1513 let mut args = send_args(Transport::WebPush, &subscription_json());
1514 args.data = Some("{\"order\":\"A-17\"}".to_owned());
1515 SendRequest::from_args(&args).expect("an object is what the flag is for");
1516
1517 let mut args = send_args(Transport::WebPush, &subscription_json());
1519 args.data = Some("{oops".to_owned());
1520 let Err(error) = SendRequest::from_args(&args) else {
1521 panic!("`{{oops` is not JSON");
1522 };
1523 assert!(error.contains("not JSON"), "{error}");
1524 }
1525
1526 #[test]
1527 fn the_set_them_hint_names_only_variables_that_have_to_be_set() {
1528 let request = SendRequest::from_args(&send_args(Transport::Apns, " DEVICE-TOKEN "))
1533 .expect("a device token");
1534 let rendered = plan(
1535 &MapConfig::from_pairs(Vec::<(String, String)>::new()),
1536 &request,
1537 )
1538 .render();
1539 assert!(rendered.contains("Set them"), "{rendered}");
1540 for var in vars_for(Platform::Ios) {
1541 assert_eq!(
1542 rendered.contains(var.name()),
1543 var.required,
1544 "{} is required={} but {} in the hint:\n{rendered}",
1545 var.name(),
1546 var.required,
1547 if var.required {
1548 "missing from"
1549 } else {
1550 "named in"
1551 }
1552 );
1553 }
1554 }
1555
1556 #[test]
1563 #[cfg(not(feature = "push-send"))]
1564 fn the_refusal_points_at_an_installed_binary_not_at_a_venture_dependency() {
1565 let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1566 .expect("parses");
1567 let Err(error) = send_now(&web_push_config(), &request) else {
1568 panic!("no client, no send");
1569 };
1570 assert!(
1571 error.contains("cargo install cratefield-cli --features push-send"),
1572 "{error}"
1573 );
1574 assert!(
1575 !error.contains("cratefield-cli = {"),
1576 "the refusal must not hand out a dependency recipe: {error}"
1577 );
1578 assert!(error.contains("--dry-run"), "{error}");
1579 }
1580
1581 #[test]
1587 #[cfg(feature = "push-send")]
1588 fn the_send_stack_is_built_on_a_clock_that_can_actually_time_out() {
1589 let (_http, clock) = send_stack();
1590 let runtime = tokio::runtime::Builder::new_current_thread()
1591 .enable_all()
1592 .build()
1593 .expect("a runtime");
1594 let outcome = runtime.block_on(async move {
1595 cratefield_core::timeout(
1596 &*clock,
1597 async {
1598 tokio::time::sleep(Duration::from_secs(5)).await;
1599 "the upstream answered eventually"
1600 },
1601 Duration::from_millis(10),
1602 )
1603 .await
1604 });
1605 assert!(
1606 outcome.is_none(),
1607 "the deadline did not fire: this clock runs the future to completion, so every \
1608 bound `BoundedHttpClient` puts on a send is decoration"
1609 );
1610 }
1611
1612 fn web_push_config() -> MapConfig {
1614 MapConfig::from_pairs([
1615 (
1616 PushKey::VapidPrivateKey.name().to_owned(),
1617 TEST_P256_PEM.to_owned(),
1618 ),
1619 (
1620 PushKey::VapidSubject.name().to_owned(),
1621 TEST_VAPID_SUBJECT.to_owned(),
1622 ),
1623 ])
1624 }
1625
1626 fn apns_config() -> MapConfig {
1629 MapConfig::from_pairs([
1630 (
1631 PushKey::ApnsKeyP8.name().to_owned(),
1632 TEST_P256_PEM.to_owned(),
1633 ),
1634 (
1635 PushKey::ApnsKeyId.name().to_owned(),
1636 "ABCDE12345".to_owned(),
1637 ),
1638 (
1639 PushKey::ApnsTeamId.name().to_owned(),
1640 "TEAM123456".to_owned(),
1641 ),
1642 (
1643 PushKey::ApnsTopic.name().to_owned(),
1644 "ventures.factory0.example".to_owned(),
1645 ),
1646 ])
1647 }
1648}