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, self.transport)
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 over `transport`. The
835/// error's own message says what happened; this says what it means.
836fn advice(err: &PushError, transport: Platform) -> &'static str {
837    match (err, transport) {
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. Every adapter
840        // answers it, so what comes after the shared sentence has to be
841        // true of the transport that answered.
842        (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
863/// What a JSON value is, for the `--data` refusal. Never the value: a
864/// payload is the operator's, and this module prints no value it was given.
865fn 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
876/// The environment variables one transport **needs**, listed for the "set
877/// them" hint.
878///
879/// Required only. The hint is an instruction, and `APNS_HOST` is optional
880/// with a documented default (`sandbox`) whose only wrong value is a hard
881/// failure — telling an operator to set it invites them to guess
882/// `production` for a development build's token, which fails every send.
883/// An optional variable is described in `docs/PUSH-ENV.md`, not prescribed
884/// here.
885fn 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
893/// The recipient for one transport, from what the operator passed.
894fn 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
912// ---------------------------------------------------------------------------
913// `fz push inspect-subscription`
914
915/// A Web Push subscription as the browser hands it over.
916struct 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/// Both shapes a subscription arrives in: the browser's own
929/// `{endpoint, keys: {p256dh, auth}}`, and the flattened form a venture that
930/// stores the three parts separately tends to write.
931#[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/// A subscription that passed every check the adapter makes.
977///
978/// It holds the push service's origin and nothing else: not the endpoint's
979/// path, which is the subscription's bearer capability, and neither key.
980#[derive(Debug)]
981pub struct SubscriptionReport {
982    /// The push service's origin: what the adapter signs as the VAPID `aud`.
983    aud: String,
984}
985
986impl SubscriptionReport {
987    /// The `aud` the Web Push adapter will sign for this subscription.
988    pub fn aud(&self) -> &str {
989        &self.aud
990    }
991
992    /// What `fz push inspect-subscription` prints.
993    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
1010/// Validates a Web Push subscription and works out the `aud` the adapter
1011/// will use for it.
1012///
1013/// Every check is the adapter's own — `origin_of` for the audience,
1014/// `SubscriptionKeys::parse` for the two keys — so a subscription this
1015/// accepts is one the adapter accepts, and a reason this gives is the reason
1016/// a send would have failed with.
1017///
1018/// # Errors
1019///
1020/// When the JSON is not a subscription, the endpoint has no usable origin,
1021/// or either key is the wrong length or encoding.
1022pub 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        // `TempDir`, not a cleanup on the last line: an assertion that
1068        // fires below would otherwise leave a `0600` private key in
1069        // `/tmp` on exactly the runs somebody then has to go and read.
1070        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        // Two runs are two keys: a keygen that returned a constant would
1086        // pass every other assertion here.
1087        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        // The type is the assertion: a disclosure that came back as a
1115        // plain `String` would drop uncleared and undo the `Zeroizing`
1116        // the field is stored in. This line stops compiling if it does.
1117        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        // The variable is named, and named through the table.
1135        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        // The warning is a diagnostic and belongs on stderr, so it is not
1175        // in what stdout gets: the README's own recipe reads the public
1176        // key off that stream.
1177        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    /// The cost of getting this wrong is stated by the command itself: a
1193    /// rotation invalidates every existing browser subscription, and none
1194    /// of them can be recreated server-side. A `--force` that truncated
1195    /// the destination first would leave an **empty file** whenever the
1196    /// write that follows fails — which is that same loss, unasked for.
1197    ///
1198    /// Staged with a directory the process cannot create a file in: the
1199    /// write cannot complete, and the question is what is left behind.
1200    #[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        // Read and execute, no write: the existing key file is still
1216        // writable through its own mode, so this is precisely the shape
1217        // where truncate-in-place succeeds at destroying it and an
1218        // atomic replacement refuses.
1219        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500))
1220            .expect("makes the directory read-only");
1221        // Root ignores the mode, and then the scenario cannot be staged
1222        // at all — skip rather than assert something else.
1223        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        // Restored before any assertion, so a failure here does not also
1234        // defeat the directory's own removal on drop.
1235        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        // The path is the subscription's bearer capability: an `aud` that
1266        // carries it earns a 401 from the services that check it strictly.
1267        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        // One byte short of a P-256 point, and an auth secret that is not 16
1277        // bytes: the two mistakes a hand-assembled subscription makes.
1278        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        // Nothing configured at all: absent is a choice, and the report says
1323        // which variables would have wired it.
1324        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        // Half-wired: the report names what is set and what is not.
1343        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        // The trap issue #218's review found: an adapter error quotes what
1378        // it was handed, and a swapped subject/private key then puts the key
1379        // in the output. Every variable in the table gets a distinctive
1380        // value here — including the swap itself — and none of them may
1381        // appear in what the operator sees.
1382        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        // And the credentials the operator supplied are not echoed either.
1399        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    /// The arm the test above never reaches, and the one that leaked.
1410    ///
1411    /// `plan()` cannot fail a send, so a report of a *failed* send was
1412    /// never exercised — while the failure is exactly where a transport's
1413    /// error message arrives, and reqwest's message ends
1414    /// ` for url (<the whole URL>)`. For Web Push that URL is the
1415    /// subscription: a bearer capability anyone who reads the output can
1416    /// push with, printed two lines under the fingerprint that was
1417    /// supposed to withhold it.
1418    ///
1419    /// The client here says what the native runtime's used to say, so
1420    /// this holds the CLI to its own promise even if a caller ever hands
1421    /// it a client that has not been fixed.
1422    #[test]
1423    fn the_failure_path_never_prints_the_recipient() {
1424        // APNs addresses a device by *path*, so the whole token is in the
1425        // URL a client reports.
1426        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        // Still diagnosable: what failed, and what to do about it.
1455        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    /// All three adapters answer `Unregistered` — APNs and Web Push on a
1476    /// 410, FCM on `UNREGISTERED` — so the advice for it is only true if
1477    /// it is the transport's own: a device token is not a subscription,
1478    /// and no browser is going to resubscribe an iPhone app.
1479    #[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        // Each of these parses as JSON and then means three different
1502        // things: APNs and FCM merge only an object's members and drop
1503        // the rest, Web Push forwards whatever it is.
1504        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        // Not JSON at all keeps its own message.
1518        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        // APNs is the transport with an optional variable: `APNS_HOST`
1529        // has a documented default, and its only wrong value — a
1530        // development token sent to `production` — fails every send. An
1531        // instruction to "set them" must not include it.
1532        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    /// The refusal is the only thing an `fz` without the feature says
1557    /// about sending, so it is the only place an operator is told how to
1558    /// get one that can — and telling them to add the feature to the
1559    /// venture's own dependency breaks the venture's wasm build, because
1560    /// `cratefield-runtime-native` `compile_error!`s on wasm32 and a
1561    /// generated venture depends on this crate beside a `cdylib`.
1562    #[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    /// The bound the port advertises has to be one that can fire. The
1582    /// clock is how `BoundedHttpClient` enforces it, and core's
1583    /// `SystemClock` has the documented test-only `timeout_any` that runs
1584    /// the future to completion — so wiring that clock advertises a
1585    /// deadline and enforces none.
1586    #[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    /// A validly configured Web Push environment, named through the table.
1613    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    /// The same for APNs: the throwaway key, and values of the shape each
1627    /// remaining variable is checked for.
1628    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}