Skip to main content

cratefield_cli/
push.rs

1//! `fz push` (issue #184): the operator half of the push transports — a
2//! VAPID key pair, one test send, and a subscription check.
3//!
4//! Three commands, each answering a question that otherwise only gets
5//! answered at send time, in production, silently:
6//!
7//! - [`vapid_keygen`] makes the P-256 key pair a venture generates once and
8//!   keeps forever, and prints the public half in the exact encoding a
9//!   browser wants.
10//! - [`send`] builds the venture's adapters from its environment and sends
11//!   one notification, printing the outcome and — when nothing was sent —
12//!   why. This is the tool the `needs-human` live proofs (issue #186) are
13//!   run with.
14//! - [`inspect_subscription`] validates a Web Push subscription and prints
15//!   the `aud` the adapter will sign, because a wrong `aud` is the
16//!   commonest cause of a VAPID `401`.
17//!
18//! # This module reads no push environment variable
19//!
20//! It calls [`cratefield_push_wiring::build_push`] — the same function
21//! `serve()` calls on both runtimes and `fz doctor` calls through
22//! `inspect_push` — so `fz push send` and the deployment it is diagnosing
23//! cannot disagree about which variables a transport reads. Where a name has
24//! to appear in prose it comes from [`PushKey::name`], not from a string
25//! literal, and `crates/cli-acceptance/tests/push_env_guard.rs` fails the
26//! build if that rule is broken here.
27//!
28//! # Nothing printed here came out of the environment
29//!
30//! An adapter's error is free to quote what it was handed, and issue #218's
31//! review found the case that costs: `VAPID_SUBJECT` and `VAPID_PRIVATE_KEY`
32//! pasted the wrong way round put the private key into a log, because the
33//! subject is validated first and its error quotes the value. So the wiring
34//! report carries only variable names and fixed phrases, and this module
35//! prints the report — never a value it read. The same rule covers what the
36//! operator supplies: a device token, a subscription `endpoint` and its
37//! `auth` secret are all credential material, so a send prints the
38//! [`Recipient`]'s fingerprinting `Debug` and a subscription check prints
39//! the endpoint's origin without its path.
40//!
41//! **The failure path is where that promise was broken.** A `FAILED` report
42//! printed the [`PushError`] verbatim, and a transport failure carries the
43//! HTTP client's own message — which for `reqwest` ends
44//! ` for url (<the whole URL>)`, the APNs device path and the Web Push
45//! bearer endpoint included. The fix is in two places, and the first is the
46//! important one: `cratefield-runtime-native`'s HTTP port no longer
47//! stringifies a `reqwest::Error` whole, so the leak is closed for
48//! `tracing::error!` and every persisted error too, not just for this
49//! command. [`SendReport`] then redacts its own recipient out of whatever
50//! reaches it anyway — this process is the one place that knows exactly
51//! which string is the capability, so it can be precise where a general
52//! rule has to be blunt. `the_failure_path_never_prints_the_recipient`
53//! holds the `Sent(Err)` arm the `plan()`-only test could not see.
54
55use 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
78/// How many draws a random P-256 scalar is given before the platform's
79/// randomness is declared broken. See [`generate_key`].
80const SCALAR_ATTEMPTS: usize = 8;
81
82/// The transport a `fz push send` is aimed at. The CLI's spelling of
83/// [`Platform`], which is core's and carries no `clap` derive.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
85pub enum Transport {
86    /// Apple Push Notification service; the recipient is a device token.
87    Apns,
88    /// Firebase Cloud Messaging; the recipient is a registration token.
89    Fcm,
90    /// Web Push (RFC 8030) — a browser subscription or a UnifiedPush
91    /// endpoint; the recipient is the subscription JSON.
92    WebPush,
93}
94
95impl Transport {
96    /// The core [`Platform`] this transport carries.
97    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/// [`Priority`] as a command-line value.
107#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, clap::ValueEnum)]
108pub enum PriorityArg {
109    /// Deliver immediately (APNs `10`, FCM `HIGH`, Web Push `Urgency: high`).
110    #[default]
111    Immediate,
112    /// Deliver when convenient, to conserve the device's power.
113    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
125// ---------------------------------------------------------------------------
126// `fz push vapid keygen`
127
128/// Where a generated VAPID private key is allowed to go.
129///
130/// One of the two has to be chosen, and [`vapid_keygen`] refuses when
131/// neither is: the public key is derived from the private one, so a run that
132/// keeps neither produces a key nobody can ever configure.
133pub struct KeygenOptions<'a> {
134    /// Write the private key here, readable by its owner only.
135    pub file: Option<&'a Path>,
136    /// Overwrite `file` if it already exists — a **rotation**, which
137    /// invalidates every existing browser subscription.
138    pub force: bool,
139    /// Print the private key to stdout as well.
140    pub print_private: bool,
141}
142
143/// A generated VAPID key pair.
144///
145/// [`render`](Self::render) never contains the private key: printing it is a
146/// separate call ([`private_key_disclosure`](Self::private_key_disclosure)),
147/// so the default path cannot leak it by getting a boolean the wrong way
148/// round.
149pub 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    /// Never prints the private key — the same rule `VapidKeys`,
158    /// `Es256Signer` and `Recipient` follow.
159    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    /// The `applicationServerKey` a browser passes to
171    /// `pushManager.subscribe()`: base64url, no padding, of the 65-byte
172    /// uncompressed P-256 point.
173    pub fn public_key(&self) -> &str {
174        &self.public_key
175    }
176
177    /// Whether an existing key was overwritten — a rotation, whose cost is
178    /// every existing subscription.
179    pub fn rotated(&self) -> bool {
180        self.rotated
181    }
182
183    /// The warning a rotation earns, or `None` when nothing was replaced.
184    ///
185    /// Separate from [`render`](Self::render) because it belongs on
186    /// **stderr**: stdout is what the README's own recipe reads the public
187    /// key off, and a diagnostic in that stream is a diagnostic in
188    /// somebody's `applicationServerKey`.
189    pub fn warning(&self) -> Option<String> {
190        self.rotated.then(rotation_warning)
191    }
192
193    /// What `fz push vapid keygen` prints on stdout. **Never** the private
194    /// key, and never the rotation warning — see [`warning`](Self::warning).
195    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    /// The private key, for `--print-private` only. A separate call so that
228    /// no default path can print it.
229    ///
230    /// Cleared on drop, like the field it copies from: a plain `String`
231    /// here would put the key back in an uncleared heap buffer and undo
232    /// the [`Zeroizing`] the field is deliberately stored in.
233    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
242/// Generates a VAPID key pair and keeps the private half where
243/// `options` says.
244///
245/// # Errors
246///
247/// When neither `file` nor `print_private` is given (the key would be
248/// discarded), when `file` exists and `force` was not given (a rotation, and
249/// the message says what it costs), when the file cannot be written, or when
250/// the platform will not produce random bytes.
251pub 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    // The scalar is encoded straight into the cleared-on-drop wrapper: the
263    // `String` the encoder returns is moved, never copied, so no second
264    // buffer holding the key is left behind for the allocator.
265    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
279/// A uniformly random P-256 key, cleared on drop, with the signer already
280/// built from it.
281///
282/// Drawn as 32 raw octets and offered to the signer, which rejects zero and
283/// anything at or above the curve order; both are astronomically unlikely
284/// (about 2^-32 for the order), so the retry is bookkeeping rather than a
285/// hot path — but retrying is the only correct answer, since clamping or
286/// reducing the value would bias the key. The same shape
287/// `cratefield_adapter_webpush::ece` uses for its per-message key, and the
288/// randomness comes through `getrandom`, whose backend this workspace
289/// selects in the final crate.
290fn 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
304/// Writes the private key, refusing to overwrite without `force`. Returns
305/// whether a key was actually replaced.
306///
307/// # The key is never truncated in place
308///
309/// A `--force` rotation replaces the only copy of a key whose loss, by this
310/// command's own warning, costs every existing browser subscription — and
311/// no server can recreate one. Opening the destination with
312/// `create(true).truncate(true)` destroys it the moment `open` returns, so
313/// a write that then fails (a full disk, an I/O error, a killed process)
314/// leaves an **empty file** where the venture's key used to be, and the
315/// operator's next `fz doctor` reports Web Push half-wired with nothing to
316/// restore.
317///
318/// So the new key is written to a temporary file beside the destination,
319/// `fsync`ed, and only then `rename`d over it. `rename` within a directory
320/// is atomic: every reader sees the old key or the new one, never a
321/// half-written one and never nothing. The temporary file is a sibling
322/// because a rename is only atomic within one filesystem.
323///
324/// The cost is that this needs to create a file in the destination's
325/// directory, so a rotation into a directory the process cannot write is
326/// now refused rather than done in place. That is the trade an atomic
327/// replacement always makes, and it fails loudly with the key intact.
328fn 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        // Not `create(true)`: `create_new` is the same refusal as the check
345        // above, taken atomically, so a file that appears between the two
346        // is still not overwritten. It is claimed empty and replaced by the
347        // rename below, so this destroys nothing either way.
348        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    // The rename itself is only durable once the *directory* is synced —
366    // best effort, because a filesystem that will not open a directory
367    // (Windows) has still had the file's own contents synced above.
368    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
378/// A path beside `path` for the new key to be written to first. Same
379/// directory, because `rename` is only atomic within one filesystem, and
380/// dot-prefixed so a half-written key does not look like a key.
381fn 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
392/// How every file this command creates is opened: new, and owner-readable
393/// only from the moment it exists — never widened afterwards, which would
394/// leave a window in which the key is world-readable.
395fn 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
406/// Writes `key` to a file that must not already exist, and `fsync`s it:
407/// without the sync the rename can land while the contents are still in
408/// the page cache, which is the same empty-file outcome by another route.
409fn 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
415/// Why an existing key file is not overwritten.
416fn 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
425/// The banner a rotation prints, after the fact.
426fn rotation_warning() -> String {
427    format!("warning: the VAPID key was rotated. {ROTATION_COST}\n")
428}
429
430/// What rotating a VAPID key costs, said once so the refusal and the warning
431/// cannot drift apart.
432const 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
438// ---------------------------------------------------------------------------
439// `fz push send`
440
441/// The command line of `fz push send`, before anything is parsed.
442pub struct SendArgs {
443    /// Which transport to send over; also how `recipient` is read.
444    pub transport: Transport,
445    /// An APNs device token, an FCM registration token, or a Web Push
446    /// subscription JSON.
447    pub recipient: String,
448    /// The notification's title.
449    pub title: String,
450    /// The notification's body.
451    pub body: String,
452    /// Custom JSON payload the app reads.
453    pub data: Option<String>,
454    /// Where a tap should take the user.
455    pub url: Option<String>,
456    /// How long the push service may hold the notification, in seconds.
457    pub ttl: Option<u64>,
458    /// Delivery priority.
459    pub priority: PriorityArg,
460    /// A data-only notification: nothing is shown, the app is woken.
461    pub silent: bool,
462}
463
464/// One parsed send: a recipient and the notification for it.
465pub struct SendRequest {
466    recipient: Recipient,
467    notification: Notification,
468}
469
470impl SendRequest {
471    /// Parses the command line into a recipient and a notification, without
472    /// touching the environment or the network.
473    ///
474    /// # Errors
475    ///
476    /// When the recipient does not parse for the named transport, or
477    /// `--data` is not a JSON **object**.
478    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                // An object, not merely JSON. `--data '["a"]'` parses, and
485                // then means three different things: APNs and FCM drop a
486                // non-object payload silently (each merges its members into
487                // a JSON object it is building), while Web Push forwards
488                // the array to the service worker. One flag cannot mean
489                // "delivered", "dropped" and "delivered differently"
490                // depending on the transport — and the message already
491                // promised an object.
492                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    /// The transport this send is routed over — the recipient's, so the
520    /// adapter that takes it and the verdict that is reported are about the
521    /// same transport by construction.
522    pub fn transport(&self) -> Platform {
523        self.recipient.platform()
524    }
525}
526
527/// Sends one notification through the adapters this venture's environment
528/// configures.
529///
530/// The adapters come from [`build_push`] — the construction `serve()` uses,
531/// not a second wiring — so a transport this reaches is a transport the
532/// deployment reaches, and one it reports unrouted is one every production
533/// send would answer `NotConfigured` for.
534pub 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
550/// What [`send`] would do, without sending: the same wiring verdict for the
551/// same transport, from [`inspect_push`], which has no HTTP client at all —
552/// so a `--dry-run` cannot reach the network however it is wired.
553pub 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/// [`send`], over the network, from a synchronous CLI.
560///
561/// The client is the native runtime's own — reqwest behind the outbound
562/// policy, wrapped in the port's bounds — and not one of this crate's
563/// invention: a live proof against a real push service that goes through a
564/// client we wrote for the occasion proves the client, not the protocol
565/// (the same reasoning `crates/adapter-webpush`'s ntfy leg gives).
566///
567/// That client is tokio's and reqwest's, which is why it is behind the
568/// `push-send` feature: `fz` is built once and installed, and everything
569/// else it does is free of both. Without the feature every other `fz push`
570/// command still works, `--dry-run` included, and this one says what to
571/// rebuild — a missing send is loud, unlike a doctor check that would
572/// silently stop checking (issue #191's rule, and the reason the doctor's
573/// push rules are *not* optional).
574///
575/// # Errors
576///
577/// Without the `push-send` feature, always. With it, when the async runtime
578/// the client needs cannot be started.
579#[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/// The client and clock [`send_now`] sends through: the native runtime's
590/// own, wired the way `serve()` wires them.
591///
592/// The clock is [`cratefield_runtime_native::TokioClock`] and **not**
593/// core's `SystemClock`, which matters more than a name: the deadline the
594/// port advertises is enforced by [`BoundedHttpClient`] *through the
595/// clock*, and `SystemClock::timeout_any` is the documented test-only
596/// default that runs the future to completion. Passing it wraps the client
597/// in a bound that cannot fire — the send would still be bounded, but only
598/// by the timeout `ReqwestClient` happens to set on itself, which is not
599/// what the wrapper is there for.
600///
601/// [`BoundedHttpClient`]: cratefield_core::BoundedHttpClient
602#[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/// The same, in an `fz` built without the `push-send` feature: there is no
613/// HTTP client, so there is no send. See the other half's documentation.
614///
615/// # Errors
616///
617/// Always.
618#[cfg(not(feature = "push-send"))]
619pub fn send_now(_config: &dyn Config, _request: &SendRequest) -> Result<SendReport, String> {
620    Err(
621        // Deliberately `cargo install`, and never "add the feature to your
622        // venture's dependency": `push-send` pulls
623        // `cratefield-runtime-native`, which `compile_error!`s on wasm32,
624        // and a generated venture depends on `cratefield-cli` beside a wasm
625        // `cdylib` — so flipping the feature there breaks the Worker build
626        // the venture actually deploys. The send-capable `fz` is a
627        // separately installed binary.
628        "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
638/// How far a send got.
639enum SendOutcome {
640    /// `--dry-run`: nothing was sent, deliberately.
641    Planned,
642    /// The transport is not routed, so nothing was sent.
643    Unrouted,
644    /// An adapter took the send and the provider answered.
645    Sent(Result<PushOutcome, PushError>),
646}
647
648/// The result of a `fz push send`, as the operator sees it.
649pub struct SendReport {
650    transport: Platform,
651    /// The recipient's fingerprinting `Debug`, taken once. Never the
652    /// recipient itself: a device token addresses a device, and a Web Push
653    /// `endpoint` is a bearer capability.
654    recipient: String,
655    /// Exactly the strings that make up this send's recipient, longest
656    /// first — see [`credential_parts`] and [`SendReport::redacted`].
657    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    /// `text` with this send's own recipient taken out of it.
674    ///
675    /// The second line of defence behind the HTTP port's own redaction,
676    /// and a deliberately different kind: the port has to guess what is
677    /// credential material in an arbitrary URL, while this process
678    /// *knows* — the operator handed it the token or the subscription on
679    /// the command line. So the match here is exact, and it costs no
680    /// diagnostic detail at all: everything that is not the recipient
681    /// survives verbatim.
682    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    /// Whether the command succeeded: a delivery, or a `--dry-run` whose
693    /// transport would actually have carried the send. Nothing sent is not a
694    /// success — the operator asked for a notification.
695    pub fn ok(&self) -> bool {
696        match &self.outcome {
697            SendOutcome::Planned => self.wiring.is_routed(),
698            SendOutcome::Sent(Ok(PushOutcome::Delivered { .. })) => true,
699            // `NotConfigured` included: the operator asked for a
700            // notification and did not get one.
701            SendOutcome::Unrouted | SendOutcome::Sent(_) => false,
702        }
703    }
704
705    /// The whole report: what was attempted, over which transport, and what
706    /// came back — with the reason spelled out whenever nothing was sent.
707    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            // Redacted, not printed: an adapter's error carries whatever
735            // the HTTP client said, and a client that names the URL it
736            // failed on names the device path or the subscription. The
737            // runtime's own client no longer does (see the module note),
738            // and this is the belt to that pair of braces — a `PushError`
739            // reaching here came from *somewhere*, and the report cannot
740            // know which client built it.
741            SendOutcome::Sent(Err(err)) => format!(
742                "result: FAILED\n  {}\n  {}\n",
743                self.redacted(&err.to_string()),
744                advice(err)
745            ),
746            // Everything else is "nothing was sent", and the reason is the
747            // transport's wiring verdict. `NotConfigured` belongs here too:
748            // the router answers it for a transport it does not carry, and
749            // an adapter for one built unconfigured — either way nothing
750            // left this process and the verdict above says which.
751            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    /// Why nothing was sent, from the transport's verdict — names and fixed
760    /// phrases only, never a value out of the environment.
761    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            // Unreachable through `send` and `plan`, which only report a
781            // reason for a transport that is not routed — but a report is
782            // not the place to panic about it.
783            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
791/// What a redacted credential is replaced by. The word the Web Push
792/// adapter and the native HTTP port both use, so one grep finds all three.
793const REDACTED: &str = "[redacted]";
794
795/// Below this length a "credential" is too short to redact by substring:
796/// replacing every occurrence of a two-character `auth` would scribble
797/// over the message instead of protecting anything. Every real one is far
798/// longer — a device token is 64 hex characters, a `p256dh` 87, an `auth`
799/// 22 — so this only ever skips a value that was never going to be
800/// accepted by the adapter anyway.
801const MIN_REDACTABLE: usize = 12;
802
803/// Every string that makes up `recipient`, longest first.
804///
805/// Longest first because the parts nest: a Web Push endpoint contains its
806/// own path, and redacting the path first would leave the origin and a
807/// `[redacted]` where the whole endpoint could have gone in one piece.
808fn 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            // The request target on its own: an intermediary that echoes
819            // what it was asked for quotes the path without the scheme
820            // and host, and that path is the whole capability.
821            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
834/// What the operator should do about a failed send. The error's own message
835/// says what happened; this says what it means.
836fn advice(err: &PushError) -> &'static str {
837    match err {
838        // The one error in the port that is an instruction, and the one it
839        // is most expensive to act on wrongly for Web Push.
840        PushError::Unregistered => {
841            "this recipient is gone: delete it from the venture's registry. A Web Push \
842             subscription cannot be recreated server-side — only the browser can, by \
843             subscribing again."
844        }
845        PushError::Rejected(_) => {
846            "the provider refused the request; it will refuse it again unchanged. Check the \
847             recipient and this transport's credentials (`fz doctor`)."
848        }
849        PushError::Transient { .. } => {
850            "retryable: the provider was unavailable or asked for a pause. Nothing about the \
851             recipient or the credentials is proven wrong by this."
852        }
853    }
854}
855
856/// What a JSON value is, for the `--data` refusal. Never the value: a
857/// payload is the operator's, and this module prints no value it was given.
858fn json_kind(value: &serde_json::Value) -> &'static str {
859    match value {
860        serde_json::Value::Null => "null",
861        serde_json::Value::Bool(_) => "a boolean",
862        serde_json::Value::Number(_) => "a number",
863        serde_json::Value::String(_) => "a string",
864        serde_json::Value::Array(_) => "an array",
865        serde_json::Value::Object(_) => "an object",
866    }
867}
868
869/// The environment variables one transport **needs**, listed for the "set
870/// them" hint.
871///
872/// Required only. The hint is an instruction, and `APNS_HOST` is optional
873/// with a documented default (`sandbox`) whose only wrong value is a hard
874/// failure — telling an operator to set it invites them to guess
875/// `production` for a development build's token, which fails every send.
876/// An optional variable is described in `docs/PUSH-ENV.md`, not prescribed
877/// here.
878fn variable_names(transport: Platform) -> String {
879    vars_for(transport)
880        .filter(|var| var.required)
881        .map(PushVar::name)
882        .collect::<Vec<_>>()
883        .join(", ")
884}
885
886/// The recipient for one transport, from what the operator passed.
887fn parse_recipient(transport: Transport, value: &str) -> Result<Recipient, String> {
888    let value = value.trim();
889    if value.is_empty() {
890        return Err("--recipient is empty".to_owned());
891    }
892    match transport {
893        Transport::WebPush => parse_subscription(value).map(Subscription::into_recipient),
894        Transport::Apns | Transport::Fcm if value.starts_with('{') => Err(format!(
895            "--recipient looks like a JSON subscription, but --transport is {}, which takes the \
896             bare token the device registered with. A subscription JSON goes with --transport \
897             web-push.",
898            transport_key(transport.platform())
899        )),
900        Transport::Apns => Ok(Recipient::apns(value)),
901        Transport::Fcm => Ok(Recipient::fcm(value)),
902    }
903}
904
905// ---------------------------------------------------------------------------
906// `fz push inspect-subscription`
907
908/// A Web Push subscription as the browser hands it over.
909struct Subscription {
910    endpoint: String,
911    p256dh: String,
912    auth: String,
913}
914
915impl Subscription {
916    fn into_recipient(self) -> Recipient {
917        Recipient::web_push(self.endpoint, self.p256dh, self.auth)
918    }
919}
920
921/// Both shapes a subscription arrives in: the browser's own
922/// `{endpoint, keys: {p256dh, auth}}`, and the flattened form a venture that
923/// stores the three parts separately tends to write.
924#[derive(Deserialize)]
925struct SubscriptionJson {
926    endpoint: Option<String>,
927    keys: Option<SubscriptionKeysJson>,
928    p256dh: Option<String>,
929    auth: Option<String>,
930}
931
932#[derive(Deserialize)]
933struct SubscriptionKeysJson {
934    p256dh: Option<String>,
935    auth: Option<String>,
936}
937
938fn parse_subscription(json: &str) -> Result<Subscription, String> {
939    let parsed: SubscriptionJson = serde_json::from_str(json)
940        .map_err(|err| format!("the subscription is not JSON: {err}. {SUBSCRIPTION_SHAPE}"))?;
941    let endpoint = parsed.endpoint.ok_or_else(|| missing_field("endpoint"))?;
942    let (p256dh, auth) = match parsed.keys {
943        Some(keys) => (
944            keys.p256dh.ok_or_else(|| missing_field("keys.p256dh"))?,
945            keys.auth.ok_or_else(|| missing_field("keys.auth"))?,
946        ),
947        None => (
948            parsed.p256dh.ok_or_else(|| missing_field("p256dh"))?,
949            parsed.auth.ok_or_else(|| missing_field("auth"))?,
950        ),
951    };
952    Ok(Subscription {
953        endpoint,
954        p256dh,
955        auth,
956    })
957}
958
959fn missing_field(field: &str) -> String {
960    format!("the subscription JSON has no `{field}`. {SUBSCRIPTION_SHAPE}")
961}
962
963const SUBSCRIPTION_SHAPE: &str = "Pass what the browser hands over — \
964                                  JSON.stringify(await registration.pushManager.getSubscription()) \
965                                  — which is {\"endpoint\":\"…\",\"keys\":{\"p256dh\":\"…\",\
966                                  \"auth\":\"…\"}}. The flattened \
967                                  {\"endpoint\":…,\"p256dh\":…,\"auth\":…} is accepted too.";
968
969/// A subscription that passed every check the adapter makes.
970///
971/// It holds the push service's origin and nothing else: not the endpoint's
972/// path, which is the subscription's bearer capability, and neither key.
973#[derive(Debug)]
974pub struct SubscriptionReport {
975    /// The push service's origin: what the adapter signs as the VAPID `aud`.
976    aud: String,
977}
978
979impl SubscriptionReport {
980    /// The `aud` the Web Push adapter will sign for this subscription.
981    pub fn aud(&self) -> &str {
982        &self.aud
983    }
984
985    /// What `fz push inspect-subscription` prints.
986    pub fn render(&self) -> String {
987        format!(
988            "subscription: valid\n\n  \
989             aud: {aud}\n    \
990             the audience the adapter signs into its VAPID token for this subscription:\n    \
991             the push service's origin, and never the subscription's path. A path-bearing\n    \
992             aud is the commonest cause of a VAPID 401; this one has none.\n  \
993             endpoint: {aud}/…\n    \
994             the path is withheld here: it is a bearer capability — whoever holds it\n    \
995             can push to this browser until the subscription dies.\n  \
996             p256dh: {PUBLIC_KEY_LEN} bytes, an uncompressed P-256 point on the curve\n  \
997             auth: {AUTH_SECRET_LEN} bytes\n",
998            aud = self.aud
999        )
1000    }
1001}
1002
1003/// Validates a Web Push subscription and works out the `aud` the adapter
1004/// will use for it.
1005///
1006/// Every check is the adapter's own — `origin_of` for the audience,
1007/// `SubscriptionKeys::parse` for the two keys — so a subscription this
1008/// accepts is one the adapter accepts, and a reason this gives is the reason
1009/// a send would have failed with.
1010///
1011/// # Errors
1012///
1013/// When the JSON is not a subscription, the endpoint has no usable origin,
1014/// or either key is the wrong length or encoding.
1015pub fn inspect_subscription(json: &str) -> Result<SubscriptionReport, String> {
1016    let subscription = parse_subscription(json)?;
1017    let aud = origin_of(&subscription.endpoint).map_err(|err| {
1018        format!(
1019            "{err} — a Web Push endpoint is the absolute http(s) URL the browser put in \
1020             `subscription.endpoint`, and its origin is what the VAPID token claims."
1021        )
1022    })?;
1023    SubscriptionKeys::parse(&subscription.p256dh, &subscription.auth)
1024        .map_err(|err| format!("{err} — this subscription cannot be encrypted to as it stands."))?;
1025    Ok(SubscriptionReport { aud })
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031    use cratefield_core::MapConfig;
1032    use cratefield_core::SystemClock;
1033    use cratefield_testing::vectors::{
1034        RFC8291_AUTH_SECRET as AUTH, RFC8291_UA_PUBLIC as P256DH, TEST_P256_PEM,
1035        TEST_VAPID_SUBJECT, WEB_PUSH_ENDPOINT as ENDPOINT, WEB_PUSH_ENDPOINT_CAPABILITY,
1036        web_push_subscription_json,
1037    };
1038    use cratefield_testing::{FakeHttpClient, TempDir};
1039
1040    fn subscription_json() -> String {
1041        web_push_subscription_json()
1042    }
1043
1044    fn send_args(transport: Transport, recipient: &str) -> SendArgs {
1045        SendArgs {
1046            transport,
1047            recipient: recipient.to_owned(),
1048            title: "title".to_owned(),
1049            body: "body".to_owned(),
1050            data: None,
1051            url: None,
1052            ttl: None,
1053            priority: PriorityArg::Immediate,
1054            silent: false,
1055        }
1056    }
1057
1058    #[test]
1059    fn a_generated_key_is_a_usable_p256_key_and_the_public_half_is_65_bytes() {
1060        // `TempDir`, not a cleanup on the last line: an assertion that
1061        // fires below would otherwise leave a `0600` private key in
1062        // `/tmp` on exactly the runs somebody then has to go and read.
1063        let dir = TempDir::new("fz-push-keygen-usable");
1064        let path = dir.join("vapid.key");
1065        let generated = vapid_keygen(&KeygenOptions {
1066            file: Some(&path),
1067            force: false,
1068            print_private: false,
1069        })
1070        .expect("generates");
1071
1072        let public = URL_SAFE_NO_PAD
1073            .decode(generated.public_key())
1074            .expect("base64url");
1075        assert_eq!(public.len(), PUBLIC_KEY_LEN, "uncompressed P-256 point");
1076        assert_eq!(public[0], 0x04, "uncompressed point form");
1077
1078        // Two runs are two keys: a keygen that returned a constant would
1079        // pass every other assertion here.
1080        let second = vapid_keygen(&KeygenOptions {
1081            file: Some(&dir.join("second.key")),
1082            force: false,
1083            print_private: false,
1084        })
1085        .expect("generates");
1086        assert_ne!(generated.public_key(), second.public_key());
1087    }
1088
1089    #[test]
1090    fn the_private_key_is_never_in_the_report_unless_it_is_asked_for() {
1091        let generated = vapid_keygen(&KeygenOptions {
1092            file: None,
1093            force: false,
1094            print_private: true,
1095        })
1096        .expect("generates");
1097        let private = generated.private_key.as_str().to_owned();
1098
1099        assert!(
1100            !generated.render().contains(&private),
1101            "the report must not carry the private key"
1102        );
1103        assert!(
1104            !format!("{generated:?}").contains(&private),
1105            "nor must Debug"
1106        );
1107        // The type is the assertion: a disclosure that came back as a
1108        // plain `String` would drop uncleared and undo the `Zeroizing`
1109        // the field is stored in. This line stops compiling if it does.
1110        let disclosure: Zeroizing<String> = generated.private_key_disclosure();
1111        assert!(
1112            disclosure.contains(&private),
1113            "--print-private asks for it explicitly"
1114        );
1115    }
1116
1117    #[test]
1118    fn a_keygen_that_would_discard_the_private_key_is_refused() {
1119        let error = vapid_keygen(&KeygenOptions {
1120            file: None,
1121            force: false,
1122            print_private: false,
1123        })
1124        .expect_err("nothing would keep the key");
1125        assert!(error.contains("--file"), "{error}");
1126        assert!(error.contains("--print-private"), "{error}");
1127        // The variable is named, and named through the table.
1128        assert!(error.contains(PushKey::VapidPrivateKey.name()), "{error}");
1129    }
1130
1131    #[test]
1132    fn an_existing_key_is_not_overwritten_without_force_and_the_warning_says_why() {
1133        let dir = TempDir::new("fz-push-keygen-force");
1134        let path = dir.join("vapid.key");
1135        let first = vapid_keygen(&KeygenOptions {
1136            file: Some(&path),
1137            force: false,
1138            print_private: false,
1139        })
1140        .expect("generates");
1141        let kept = std::fs::read_to_string(&path).expect("written");
1142
1143        let error = vapid_keygen(&KeygenOptions {
1144            file: Some(&path),
1145            force: false,
1146            print_private: false,
1147        })
1148        .expect_err("refuses to rotate");
1149        assert!(error.contains("--force"), "{error}");
1150        assert!(
1151            error.contains("invalidates every existing browser subscription"),
1152            "{error}"
1153        );
1154        assert_eq!(
1155            std::fs::read_to_string(&path).expect("still there"),
1156            kept,
1157            "the refused run left the key alone"
1158        );
1159
1160        let rotated = vapid_keygen(&KeygenOptions {
1161            file: Some(&path),
1162            force: true,
1163            print_private: false,
1164        })
1165        .expect("rotates with --force");
1166        assert!(rotated.rotated());
1167        // The warning is a diagnostic and belongs on stderr, so it is not
1168        // in what stdout gets: the README's own recipe reads the public
1169        // key off that stream.
1170        let warning = rotated.warning().expect("a rotation warns");
1171        assert!(warning.contains("rotated"), "{warning}");
1172        assert!(
1173            warning.contains("invalidates every existing browser subscription"),
1174            "{warning}"
1175        );
1176        assert!(
1177            !rotated.render().contains("warning"),
1178            "the warning is stderr's, not stdout's: {}",
1179            rotated.render()
1180        );
1181        assert_ne!(rotated.public_key(), first.public_key());
1182        assert_ne!(std::fs::read_to_string(&path).expect("rewritten"), kept);
1183    }
1184
1185    /// The cost of getting this wrong is stated by the command itself: a
1186    /// rotation invalidates every existing browser subscription, and none
1187    /// of them can be recreated server-side. A `--force` that truncated
1188    /// the destination first would leave an **empty file** whenever the
1189    /// write that follows fails — which is that same loss, unasked for.
1190    ///
1191    /// Staged with a directory the process cannot create a file in: the
1192    /// write cannot complete, and the question is what is left behind.
1193    #[test]
1194    #[cfg(unix)]
1195    fn a_force_rotation_that_cannot_complete_leaves_the_old_key_intact() {
1196        use std::os::unix::fs::PermissionsExt as _;
1197
1198        let dir = TempDir::new("fz-push-keygen-torn");
1199        let path = dir.join("vapid.key");
1200        vapid_keygen(&KeygenOptions {
1201            file: Some(&path),
1202            force: false,
1203            print_private: false,
1204        })
1205        .expect("the venture's key");
1206        let kept = std::fs::read_to_string(&path).expect("written");
1207
1208        // Read and execute, no write: the existing key file is still
1209        // writable through its own mode, so this is precisely the shape
1210        // where truncate-in-place succeeds at destroying it and an
1211        // atomic replacement refuses.
1212        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500))
1213            .expect("makes the directory read-only");
1214        // Root ignores the mode, and then the scenario cannot be staged
1215        // at all — skip rather than assert something else.
1216        let staged = std::fs::write(dir.join("probe"), "x").is_err();
1217        let outcome = if staged {
1218            Some(vapid_keygen(&KeygenOptions {
1219                file: Some(&path),
1220                force: true,
1221                print_private: false,
1222            }))
1223        } else {
1224            None
1225        };
1226        // Restored before any assertion, so a failure here does not also
1227        // defeat the directory's own removal on drop.
1228        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))
1229            .expect("restores the directory");
1230
1231        let Some(outcome) = outcome else {
1232            eprintln!("skipped: this process writes into a read-only directory (root?)");
1233            return;
1234        };
1235        let Err(error) = outcome else {
1236            panic!("the rotation could not be completed, so it must have refused");
1237        };
1238        assert!(
1239            error.contains("untouched"),
1240            "the refusal says the key survived: {error}"
1241        );
1242        assert_eq!(
1243            std::fs::read_to_string(&path).expect("the old key is still there"),
1244            kept,
1245            "a rotation that could not be completed destroyed the venture's only key"
1246        );
1247    }
1248
1249    #[test]
1250    fn a_subscription_is_read_in_both_shapes_and_its_aud_is_the_origin() {
1251        let nested = inspect_subscription(&subscription_json()).expect("valid");
1252        let flat = inspect_subscription(&format!(
1253            "{{\"endpoint\":\"{ENDPOINT}\",\"p256dh\":\"{P256DH}\",\"auth\":\"{AUTH}\"}}"
1254        ))
1255        .expect("valid");
1256        assert_eq!(nested.aud(), flat.aud());
1257        assert_eq!(nested.aud(), "https://updates.push.services.mozilla.com");
1258        // The path is the subscription's bearer capability: an `aud` that
1259        // carries it earns a 401 from the services that check it strictly.
1260        assert!(
1261            !nested.render().contains(WEB_PUSH_ENDPOINT_CAPABILITY),
1262            "{}",
1263            nested.render()
1264        );
1265    }
1266
1267    #[test]
1268    fn a_subscription_with_a_wrong_length_key_is_refused_by_name() {
1269        // One byte short of a P-256 point, and an auth secret that is not 16
1270        // bytes: the two mistakes a hand-assembled subscription makes.
1271        let short_p256dh = &P256DH[..P256DH.len() - 2];
1272        let error = inspect_subscription(&format!(
1273            "{{\"endpoint\":\"{ENDPOINT}\",\"keys\":{{\"p256dh\":\"{short_p256dh}\",\"auth\":\"{AUTH}\"}}}}"
1274        ))
1275        .expect_err("wrong length");
1276        assert!(error.contains("p256dh"), "{error}");
1277
1278        let error = inspect_subscription(&format!(
1279            "{{\"endpoint\":\"{ENDPOINT}\",\"keys\":{{\"p256dh\":\"{P256DH}\",\"auth\":\"c2hvcnQ\"}}}}"
1280        ))
1281        .expect_err("wrong length");
1282        assert!(error.contains("auth"), "{error}");
1283    }
1284
1285    #[test]
1286    fn an_endpoint_that_is_not_an_absolute_http_url_is_refused() {
1287        for endpoint in ["/relative/path", "ftp://push.example/x"] {
1288            let error = inspect_subscription(&format!(
1289                "{{\"endpoint\":\"{endpoint}\",\"keys\":{{\"p256dh\":\"{P256DH}\",\"auth\":\"{AUTH}\"}}}}"
1290            ))
1291            .expect_err("no origin");
1292            assert!(error.contains("endpoint"), "{endpoint}: {error}");
1293        }
1294    }
1295
1296    #[test]
1297    fn a_bare_token_and_a_subscription_are_not_interchangeable() {
1298        let Err(error) = SendRequest::from_args(&send_args(Transport::Apns, &subscription_json()))
1299        else {
1300            panic!("a subscription is not a device token");
1301        };
1302        assert!(error.contains("web-push"), "{error}");
1303
1304        let request = SendRequest::from_args(&send_args(Transport::Apns, "  DEVICE-TOKEN  "))
1305            .expect("a device token");
1306        assert_eq!(request.transport(), Platform::Ios);
1307
1308        let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1309            .expect("a subscription");
1310        assert_eq!(request.transport(), Platform::Web);
1311    }
1312
1313    #[test]
1314    fn an_unrouted_transport_reports_why_nothing_was_sent() {
1315        // Nothing configured at all: absent is a choice, and the report says
1316        // which variables would have wired it.
1317        let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1318            .expect("parses");
1319        let report = plan(
1320            &MapConfig::from_pairs(Vec::<(String, String)>::new()),
1321            &request,
1322        );
1323        assert!(!report.ok());
1324        let rendered = report.render();
1325        assert!(rendered.contains("NOT SENT"), "{rendered}");
1326        assert!(
1327            rendered.contains(PushKey::VapidPrivateKey.name()),
1328            "{rendered}"
1329        );
1330        assert!(
1331            rendered.contains(PushKey::VapidSubject.name()),
1332            "{rendered}"
1333        );
1334
1335        // Half-wired: the report names what is set and what is not.
1336        let half = MapConfig::from_pairs([(
1337            PushKey::VapidSubject.name().to_owned(),
1338            "mailto:ops@example.test".to_owned(),
1339        )]);
1340        let rendered = plan(&half, &request).render();
1341        assert!(rendered.contains("half-wired"), "{rendered}");
1342        assert!(
1343            rendered.contains(PushKey::VapidPrivateKey.name()),
1344            "{rendered}"
1345        );
1346    }
1347
1348    #[test]
1349    fn a_dry_run_of_a_configured_transport_says_it_would_have_sent() {
1350        let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1351            .expect("parses");
1352        let report = plan(&web_push_config(), &request);
1353        assert!(report.ok(), "{}", report.render());
1354        assert!(report.render().contains("--dry-run"), "{}", report.render());
1355    }
1356
1357    #[test]
1358    fn a_configured_transport_sends_and_reports_the_outcome() {
1359        let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1360            .expect("parses");
1361        let http: Arc<dyn HttpClient> = Arc::new(FakeHttpClient::ok_json(""));
1362        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
1363        let report = pollster::block_on(send(&web_push_config(), &http, &clock, &request));
1364        assert!(report.ok(), "{}", report.render());
1365        assert!(report.render().contains("DELIVERED"), "{}", report.render());
1366    }
1367
1368    #[test]
1369    fn the_report_never_prints_a_value_out_of_the_environment() {
1370        // The trap issue #218's review found: an adapter error quotes what
1371        // it was handed, and a swapped subject/private key then puts the key
1372        // in the output. Every variable in the table gets a distinctive
1373        // value here — including the swap itself — and none of them may
1374        // appear in what the operator sees.
1375        let sentinel = |var: &PushVar| format!("SENTINEL-{}-VALUE", var.name());
1376        let config = MapConfig::from_pairs(
1377            cratefield_push_wiring::PUSH_ENV
1378                .iter()
1379                .map(|var| (var.name().to_owned(), sentinel(var))),
1380        );
1381        let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1382            .expect("parses");
1383        let rendered = plan(&config, &request).render();
1384        for var in cratefield_push_wiring::PUSH_ENV {
1385            assert!(
1386                !rendered.contains(&sentinel(var)),
1387                "{} leaked into the report:\n{rendered}",
1388                var.name()
1389            );
1390        }
1391        // And the credentials the operator supplied are not echoed either.
1392        assert!(
1393            !rendered.contains(AUTH),
1394            "the auth secret leaked:\n{rendered}"
1395        );
1396        assert!(
1397            !rendered.contains(WEB_PUSH_ENDPOINT_CAPABILITY),
1398            "the endpoint leaked:\n{rendered}"
1399        );
1400    }
1401
1402    /// The arm the test above never reaches, and the one that leaked.
1403    ///
1404    /// `plan()` cannot fail a send, so a report of a *failed* send was
1405    /// never exercised — while the failure is exactly where a transport's
1406    /// error message arrives, and reqwest's message ends
1407    /// ` for url (<the whole URL>)`. For Web Push that URL is the
1408    /// subscription: a bearer capability anyone who reads the output can
1409    /// push with, printed two lines under the fingerprint that was
1410    /// supposed to withhold it.
1411    ///
1412    /// The client here says what the native runtime's used to say, so
1413    /// this holds the CLI to its own promise even if a caller ever hands
1414    /// it a client that has not been fixed.
1415    #[test]
1416    fn the_failure_path_never_prints_the_recipient() {
1417        // APNs addresses a device by *path*, so the whole token is in the
1418        // URL a client reports.
1419        const DEVICE_TOKEN: &str =
1420            "0a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff";
1421
1422        let leaky = |target: &str| {
1423            let http: Arc<dyn HttpClient> = Arc::new(FakeHttpClient::scripted(vec![Err(
1424                cratefield_core::HttpError::Transport(format!(
1425                    "error sending request for url ({target})"
1426                )),
1427            )]));
1428            http
1429        };
1430        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
1431
1432        let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1433            .expect("parses");
1434        let report =
1435            pollster::block_on(send(&web_push_config(), &leaky(ENDPOINT), &clock, &request));
1436        let rendered = report.render();
1437        assert!(!report.ok(), "{rendered}");
1438        assert!(rendered.contains("FAILED"), "{rendered}");
1439        assert!(
1440            !rendered.contains(WEB_PUSH_ENDPOINT_CAPABILITY),
1441            "the subscription's bearer capability leaked through the failure:\n{rendered}"
1442        );
1443        assert!(
1444            !rendered.contains(ENDPOINT),
1445            "the endpoint leaked through the failure:\n{rendered}"
1446        );
1447        // Still diagnosable: what failed, and what to do about it.
1448        assert!(rendered.contains("retryable"), "{rendered}");
1449
1450        let request = SendRequest::from_args(&send_args(Transport::Apns, DEVICE_TOKEN))
1451            .expect("a device token");
1452        let report = pollster::block_on(send(
1453            &apns_config(),
1454            &leaky(&format!(
1455                "https://api.push.apple.com/3/device/{DEVICE_TOKEN}"
1456            )),
1457            &clock,
1458            &request,
1459        ));
1460        let rendered = report.render();
1461        assert!(rendered.contains("FAILED"), "{rendered}");
1462        assert!(
1463            !rendered.contains(DEVICE_TOKEN),
1464            "the device token leaked through the failure:\n{rendered}"
1465        );
1466    }
1467
1468    #[test]
1469    fn data_has_to_be_a_json_object() {
1470        // Each of these parses as JSON and then means three different
1471        // things: APNs and FCM merge only an object's members and drop
1472        // the rest, Web Push forwards whatever it is.
1473        for raw in ["[1,2]", "\"a string\"", "42", "null", "true"] {
1474            let mut args = send_args(Transport::WebPush, &subscription_json());
1475            args.data = Some(raw.to_owned());
1476            let Err(error) = SendRequest::from_args(&args) else {
1477                panic!("{raw} is not a JSON object");
1478            };
1479            assert!(error.contains("JSON object"), "{raw}: {error}");
1480        }
1481
1482        let mut args = send_args(Transport::WebPush, &subscription_json());
1483        args.data = Some("{\"order\":\"A-17\"}".to_owned());
1484        SendRequest::from_args(&args).expect("an object is what the flag is for");
1485
1486        // Not JSON at all keeps its own message.
1487        let mut args = send_args(Transport::WebPush, &subscription_json());
1488        args.data = Some("{oops".to_owned());
1489        let Err(error) = SendRequest::from_args(&args) else {
1490            panic!("`{{oops` is not JSON");
1491        };
1492        assert!(error.contains("not JSON"), "{error}");
1493    }
1494
1495    #[test]
1496    fn the_set_them_hint_names_only_variables_that_have_to_be_set() {
1497        // APNs is the transport with an optional variable: `APNS_HOST`
1498        // has a documented default, and its only wrong value — a
1499        // development token sent to `production` — fails every send. An
1500        // instruction to "set them" must not include it.
1501        let request = SendRequest::from_args(&send_args(Transport::Apns, "  DEVICE-TOKEN  "))
1502            .expect("a device token");
1503        let rendered = plan(
1504            &MapConfig::from_pairs(Vec::<(String, String)>::new()),
1505            &request,
1506        )
1507        .render();
1508        assert!(rendered.contains("Set them"), "{rendered}");
1509        for var in vars_for(Platform::Ios) {
1510            assert_eq!(
1511                rendered.contains(var.name()),
1512                var.required,
1513                "{} is required={} but {} in the hint:\n{rendered}",
1514                var.name(),
1515                var.required,
1516                if var.required {
1517                    "missing from"
1518                } else {
1519                    "named in"
1520                }
1521            );
1522        }
1523    }
1524
1525    /// The refusal is the only thing an `fz` without the feature says
1526    /// about sending, so it is the only place an operator is told how to
1527    /// get one that can — and telling them to add the feature to the
1528    /// venture's own dependency breaks the venture's wasm build, because
1529    /// `cratefield-runtime-native` `compile_error!`s on wasm32 and a
1530    /// generated venture depends on this crate beside a `cdylib`.
1531    #[test]
1532    #[cfg(not(feature = "push-send"))]
1533    fn the_refusal_points_at_an_installed_binary_not_at_a_venture_dependency() {
1534        let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
1535            .expect("parses");
1536        let Err(error) = send_now(&web_push_config(), &request) else {
1537            panic!("no client, no send");
1538        };
1539        assert!(
1540            error.contains("cargo install cratefield-cli --features push-send"),
1541            "{error}"
1542        );
1543        assert!(
1544            !error.contains("cratefield-cli = {"),
1545            "the refusal must not hand out a dependency recipe: {error}"
1546        );
1547        assert!(error.contains("--dry-run"), "{error}");
1548    }
1549
1550    /// The bound the port advertises has to be one that can fire. The
1551    /// clock is how `BoundedHttpClient` enforces it, and core's
1552    /// `SystemClock` has the documented test-only `timeout_any` that runs
1553    /// the future to completion — so wiring that clock advertises a
1554    /// deadline and enforces none.
1555    #[test]
1556    #[cfg(feature = "push-send")]
1557    fn the_send_stack_is_built_on_a_clock_that_can_actually_time_out() {
1558        let (_http, clock) = send_stack();
1559        let runtime = tokio::runtime::Builder::new_current_thread()
1560            .enable_all()
1561            .build()
1562            .expect("a runtime");
1563        let outcome = runtime.block_on(async move {
1564            cratefield_core::timeout(
1565                &*clock,
1566                async {
1567                    tokio::time::sleep(Duration::from_secs(5)).await;
1568                    "the upstream answered eventually"
1569                },
1570                Duration::from_millis(10),
1571            )
1572            .await
1573        });
1574        assert!(
1575            outcome.is_none(),
1576            "the deadline did not fire: this clock runs the future to completion, so every \
1577             bound `BoundedHttpClient` puts on a send is decoration"
1578        );
1579    }
1580
1581    /// A validly configured Web Push environment, named through the table.
1582    fn web_push_config() -> MapConfig {
1583        MapConfig::from_pairs([
1584            (
1585                PushKey::VapidPrivateKey.name().to_owned(),
1586                TEST_P256_PEM.to_owned(),
1587            ),
1588            (
1589                PushKey::VapidSubject.name().to_owned(),
1590                TEST_VAPID_SUBJECT.to_owned(),
1591            ),
1592        ])
1593    }
1594
1595    /// The same for APNs: the throwaway key, and values of the shape each
1596    /// remaining variable is checked for.
1597    fn apns_config() -> MapConfig {
1598        MapConfig::from_pairs([
1599            (
1600                PushKey::ApnsKeyP8.name().to_owned(),
1601                TEST_P256_PEM.to_owned(),
1602            ),
1603            (
1604                PushKey::ApnsKeyId.name().to_owned(),
1605                "ABCDE12345".to_owned(),
1606            ),
1607            (
1608                PushKey::ApnsTeamId.name().to_owned(),
1609                "TEAM123456".to_owned(),
1610            ),
1611            (
1612                PushKey::ApnsTopic.name().to_owned(),
1613                "ventures.factory0.example".to_owned(),
1614            ),
1615        ])
1616    }
1617}