use std::fmt;
use std::fmt::Write as _;
use std::fs::OpenOptions;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use cratefield_adapter_webpush::ece::{AUTH_SECRET_LEN, PUBLIC_KEY_LEN, SubscriptionKeys};
use cratefield_adapter_webpush::vapid::origin_of;
use cratefield_core::{
Clock, Config, HttpClient, Notification, Platform, Priority, PushError, PushOutcome, Recipient,
};
use cratefield_push_auth::Es256Signer;
use cratefield_push_wiring::{
PushKey, PushVar, TransportWiring, build_push, inspect_push, transport_key, transport_name,
vars_for,
};
use serde::Deserialize;
use zeroize::Zeroizing;
const SCALAR_ATTEMPTS: usize = 8;
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum Transport {
Apns,
Fcm,
WebPush,
}
impl Transport {
pub fn platform(self) -> Platform {
match self {
Transport::Apns => Platform::Ios,
Transport::Fcm => Platform::Android,
Transport::WebPush => Platform::Web,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, clap::ValueEnum)]
pub enum PriorityArg {
#[default]
Immediate,
Conserve,
}
impl PriorityArg {
fn priority(self) -> Priority {
match self {
PriorityArg::Immediate => Priority::Immediate,
PriorityArg::Conserve => Priority::Conserve,
}
}
}
pub struct KeygenOptions<'a> {
pub file: Option<&'a Path>,
pub force: bool,
pub print_private: bool,
}
pub struct VapidKeygen {
public_key: String,
private_key: Zeroizing<String>,
written_to: Option<PathBuf>,
rotated: bool,
}
impl fmt::Debug for VapidKeygen {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VapidKeygen")
.field("public_key", &self.public_key)
.field("private_key", &"[redacted]")
.field("written_to", &self.written_to)
.field("rotated", &self.rotated)
.finish()
}
}
impl VapidKeygen {
pub fn public_key(&self) -> &str {
&self.public_key
}
pub fn rotated(&self) -> bool {
self.rotated
}
pub fn warning(&self) -> Option<String> {
self.rotated.then(rotation_warning)
}
pub fn render(&self) -> String {
let mut out = String::new();
out.push_str("VAPID key pair generated.\n\n");
let _ = writeln!(
out,
" public key (base64url, the 65-byte uncompressed P-256 point a browser\n \
passes to pushManager.subscribe() as applicationServerKey):\n\n {}\n",
self.public_key
);
if let Some(path) = &self.written_to {
let _ = writeln!(
out,
" private key written to {} (owner-readable only)\n",
path.display()
);
}
let private = PushKey::VapidPrivateKey.name();
let subject = PushKey::VapidSubject.name();
let _ = write!(
out,
"Next steps:\n \
1. Configure the private key as {private} and a `mailto:` or `https:` contact\n \
URI as {subject} (docs/PUSH-ENV.md). On Workers:\n\n \
wrangler secret put {private}\n\n \
2. Run `fz doctor` — it reports whether this venture routes Web Push, and\n \
fails a production deploy that half-wires it.\n \
3. Do not write the public key down anywhere it is configured: it is derived\n \
from the private key on every boot, so the two cannot drift.\n"
);
out
}
pub fn private_key_disclosure(&self) -> Zeroizing<String> {
Zeroizing::new(format!(
" private key (base64url, the 32-byte P-256 scalar) — this is a secret;\n \
it is on your screen and in this shell's scrollback:\n\n {}\n",
self.private_key.as_str()
))
}
}
pub fn vapid_keygen(options: &KeygenOptions<'_>) -> Result<VapidKeygen, String> {
if options.file.is_none() && !options.print_private {
return Err(format!(
"nothing would keep the private key. Pass --file <PATH> to write it \
(owner-readable only), or --print-private to print it. The public key alone is \
useless: it is derived from the private one, and a {} nobody has cannot be \
configured.",
PushKey::VapidPrivateKey.name()
));
}
let (signer, scalar) = generate_key()?;
let private_key = Zeroizing::new(URL_SAFE_NO_PAD.encode(scalar.as_slice()));
let public_key = URL_SAFE_NO_PAD.encode(signer.public_key_uncompressed());
let rotated = match options.file {
Some(path) => write_private_key(path, &private_key, options.force)?,
None => false,
};
Ok(VapidKeygen {
public_key,
private_key,
written_to: options.file.map(Path::to_path_buf),
rotated,
})
}
fn generate_key() -> Result<(Es256Signer, Zeroizing<[u8; 32]>), String> {
let mut scalar = Zeroizing::new([0u8; 32]);
for _ in 0..SCALAR_ATTEMPTS {
getrandom::fill(scalar.as_mut_slice())
.map_err(|err| format!("could not draw random bytes for a VAPID key: {err}"))?;
if let Ok(signer) = Es256Signer::from_scalar(&scalar) {
return Ok((signer, scalar));
}
}
Err(format!(
"no valid P-256 scalar in {SCALAR_ATTEMPTS} draws — this platform's randomness is broken"
))
}
fn write_private_key(path: &Path, key: &str, force: bool) -> Result<bool, String> {
let existed = path.exists();
if existed && !force {
return Err(rotation_refusal(path));
}
let temp = temp_sibling(path);
write_new_key_file(&temp, key).map_err(|err| {
let _ = std::fs::remove_file(&temp);
format!(
"cannot write the private key beside {}: {err}. The existing key, if any, is \
untouched.",
path.display()
)
})?;
if !existed {
if let Err(err) = new_file_options().open(path) {
let _ = std::fs::remove_file(&temp);
return Err(if err.kind() == std::io::ErrorKind::AlreadyExists {
rotation_refusal(path)
} else {
format!("cannot write the private key to {}: {err}", path.display())
});
}
}
std::fs::rename(&temp, path).map_err(|err| {
let _ = std::fs::remove_file(&temp);
format!(
"cannot move the new private key into {}: {err}. The existing key, if any, is \
untouched.",
path.display()
)
})?;
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
&& let Ok(dir) = std::fs::File::open(parent)
{
let _ = dir.sync_all();
}
Ok(existed)
}
fn temp_sibling(path: &Path) -> PathBuf {
let name = path.file_name().map_or_else(
|| "vapid.key".to_owned(),
|name| name.to_string_lossy().into_owned(),
);
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
path.with_file_name(format!(".{name}.{}.{unique}.tmp", std::process::id()))
}
fn new_file_options() -> OpenOptions {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
options
}
fn write_new_key_file(path: &Path, key: &str) -> std::io::Result<()> {
let mut file = new_file_options().open(path)?;
writeln!(file, "{key}")?;
file.sync_all()
}
fn rotation_refusal(path: &Path) -> String {
format!(
"{} already exists, and overwriting it would rotate this venture's VAPID key. {} Pass \
--force if that is what you mean, or write the new key somewhere else.",
path.display(),
ROTATION_COST
)
}
fn rotation_warning() -> String {
format!("warning: the VAPID key was rotated. {ROTATION_COST}\n")
}
const ROTATION_COST: &str = "A subscription is bound to the application server key it was \
created with, so rotating invalidates every existing browser \
subscription — and no server can recreate one: each browser has to \
call pushManager.subscribe() again, which needs the user back on \
the site with notification permission still granted.";
pub struct SendArgs {
pub transport: Transport,
pub recipient: String,
pub title: String,
pub body: String,
pub data: Option<String>,
pub url: Option<String>,
pub ttl: Option<u64>,
pub priority: PriorityArg,
pub silent: bool,
}
pub struct SendRequest {
recipient: Recipient,
notification: Notification,
}
impl SendRequest {
pub fn from_args(args: &SendArgs) -> Result<Self, String> {
let recipient = parse_recipient(args.transport, &args.recipient)?;
let data = match &args.data {
Some(raw) => {
let value: serde_json::Value = serde_json::from_str(raw)
.map_err(|err| format!("--data is not JSON: {err}. Pass a JSON object."))?;
if !value.is_object() {
return Err(format!(
"--data is {}, not a JSON object. Pass an object — \
`--data '{{\"key\":\"value\"}}'` — because a payload that is not one is \
silently dropped by APNs and FCM and forwarded by Web Push, so the same \
flag would mean three things.",
json_kind(&value)
));
}
value
}
None => serde_json::Value::Null,
};
let notification = Notification {
data,
url: args.url.clone(),
ttl: args.ttl.map(Duration::from_secs),
priority: args.priority.priority(),
silent: args.silent,
..Notification::new(args.title.clone(), args.body.clone())
};
Ok(Self {
recipient,
notification,
})
}
pub fn transport(&self) -> Platform {
self.recipient.platform()
}
}
pub async fn send(
config: &dyn Config,
http: &Arc<dyn HttpClient>,
clock: &Arc<dyn Clock>,
request: &SendRequest,
) -> SendReport {
let (push, wiring) = build_push(config, http, clock);
let transport = request.transport();
let verdict = wiring.get(transport).clone();
if !verdict.is_routed() {
return SendReport::new(request, verdict, SendOutcome::Unrouted);
}
let outcome = push.send(&request.recipient, &request.notification).await;
SendReport::new(request, verdict, SendOutcome::Sent(outcome))
}
pub fn plan(config: &dyn Config, request: &SendRequest) -> SendReport {
let wiring = inspect_push(config);
let verdict = wiring.get(request.transport()).clone();
SendReport::new(request, verdict, SendOutcome::Planned)
}
#[cfg(feature = "push-send")]
pub fn send_now(config: &dyn Config, request: &SendRequest) -> Result<SendReport, String> {
let (http, clock) = send_stack();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|err| format!("cannot start the async runtime this send needs: {err}"))?;
Ok(runtime.block_on(send(config, &http, &clock, request)))
}
#[cfg(feature = "push-send")]
fn send_stack() -> (Arc<dyn HttpClient>, Arc<dyn Clock>) {
let clock: Arc<dyn Clock> = Arc::new(cratefield_runtime_native::TokioClock);
let http: Arc<dyn HttpClient> = Arc::new(cratefield_core::BoundedHttpClient::new(
Arc::new(cratefield_runtime_native::ReqwestClient::new()),
Arc::clone(&clock),
));
(http, clock)
}
#[cfg(not(feature = "push-send"))]
pub fn send_now(_config: &dyn Config, _request: &SendRequest) -> Result<SendReport, String> {
Err(
"this `fz` was built without the `push-send` feature, so it has no HTTP client and \
cannot send. Install one that has it — `cargo install cratefield-cli --features \
push-send` — and run that binary (it needs no compiled-in harness); do not add the \
feature to the venture's own `cratefield-cli` dependency, which would pull the native \
runtime into its wasm build. Or run `fz push send --dry-run`, which reports the \
transport's wiring without sending."
.to_owned(),
)
}
enum SendOutcome {
Planned,
Unrouted,
Sent(Result<PushOutcome, PushError>),
}
pub struct SendReport {
transport: Platform,
recipient: String,
credentials: Vec<String>,
wiring: TransportWiring,
outcome: SendOutcome,
}
impl SendReport {
fn new(request: &SendRequest, wiring: TransportWiring, outcome: SendOutcome) -> Self {
Self {
transport: request.transport(),
recipient: format!("{:?}", request.recipient),
credentials: credential_parts(&request.recipient),
wiring,
outcome,
}
}
fn redacted(&self, text: &str) -> String {
let mut out = text.to_owned();
for part in &self.credentials {
if out.contains(part.as_str()) {
out = out.replace(part.as_str(), REDACTED);
}
}
out
}
pub fn ok(&self) -> bool {
match &self.outcome {
SendOutcome::Planned => self.wiring.is_routed(),
SendOutcome::Sent(Ok(PushOutcome::Delivered { .. })) => true,
SendOutcome::Unrouted | SendOutcome::Sent(_) => false,
}
}
pub fn render(&self) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"transport: {} ({})",
transport_key(self.transport),
transport_name(self.transport)
);
let _ = writeln!(out, "recipient: {}", self.recipient);
let _ = writeln!(out, "wiring: {}", self.wiring.verdict());
out.push('\n');
out.push_str(&self.result());
out
}
fn result(&self) -> String {
match &self.outcome {
SendOutcome::Planned if self.wiring.is_routed() => format!(
"result: NOT SENT (--dry-run)\n {} is configured and would have carried this \
notification. Drop --dry-run to send it.\n",
transport_name(self.transport)
),
SendOutcome::Sent(Ok(PushOutcome::Delivered { id })) => match id {
Some(id) => format!("result: DELIVERED\n the push service accepted it: {id}\n"),
None => "result: DELIVERED\n the push service accepted it (it named no id)\n"
.to_owned(),
},
SendOutcome::Sent(Err(err)) => format!(
"result: FAILED\n {}\n {}\n",
self.redacted(&err.to_string()),
advice(err, self.transport)
),
SendOutcome::Planned
| SendOutcome::Unrouted
| SendOutcome::Sent(Ok(PushOutcome::NotConfigured)) => {
format!("result: NOT SENT\n {}\n", self.unrouted_reason())
}
}
}
fn unrouted_reason(&self) -> String {
let name = transport_name(self.transport);
match &self.wiring {
TransportWiring::Absent => format!(
"not one of {name}'s variables is set here ({}), so this venture does not wire \
{name} at all and nothing was sent. Set them (docs/PUSH-ENV.md), or send over a \
transport this venture configures.",
variable_names(self.transport)
),
TransportWiring::Partial { present, missing } => format!(
"{name} is half-wired — {} set, {} not — so it is left unrouted and nothing was \
sent. `fz doctor` reports the same thing, and fails a production deploy on it.",
present.join(", "),
missing.join(", ")
),
TransportWiring::Invalid { reason } => format!(
"{name}'s credentials were refused: {reason}. It is left unrouted, so nothing was \
sent and every deployed send would answer NotConfigured too."
),
TransportWiring::Configured => format!(
"{name} is configured, but no adapter took the send. This is a \
cratefield-push-wiring bug: please file it."
),
}
}
}
const REDACTED: &str = "[redacted]";
const MIN_REDACTABLE: usize = 12;
fn credential_parts(recipient: &Recipient) -> Vec<String> {
let mut parts = match recipient {
Recipient::Apns { device_token } => vec![device_token.clone()],
Recipient::Fcm { registration_token } => vec![registration_token.clone()],
Recipient::WebPush {
endpoint,
p256dh,
auth,
} => {
let mut parts = vec![endpoint.clone(), p256dh.clone(), auth.clone()];
if let Some(rest) = endpoint.split_once("://").map(|(_, rest)| rest)
&& let Some(index) = rest.find('/')
{
parts.push(rest[index..].to_owned());
}
parts
}
};
parts.retain(|part| part.len() >= MIN_REDACTABLE);
parts.sort_by_key(|part| std::cmp::Reverse(part.len()));
parts
}
fn advice(err: &PushError, transport: Platform) -> &'static str {
match (err, transport) {
(PushError::Unregistered, Platform::Web) => {
"this recipient is gone: delete it from the venture's registry. A Web Push \
subscription cannot be recreated server-side — only the browser can, by \
subscribing again."
}
(PushError::Unregistered, Platform::Ios | Platform::Android) => {
"this recipient is gone: delete it from the venture's registry. A device token \
cannot be minted server-side — only the app on the device can get a new one, by \
registering for push again."
}
(PushError::Rejected(_), _) => {
"the provider refused the request; it will refuse it again unchanged. Check the \
recipient and this transport's credentials (`fz doctor`)."
}
(PushError::Transient { .. }, _) => {
"retryable: the provider was unavailable or asked for a pause. Nothing about the \
recipient or the credentials is proven wrong by this."
}
}
}
fn json_kind(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Number(_) => "a number",
serde_json::Value::String(_) => "a string",
serde_json::Value::Array(_) => "an array",
serde_json::Value::Object(_) => "an object",
}
}
fn variable_names(transport: Platform) -> String {
vars_for(transport)
.filter(|var| var.required)
.map(PushVar::name)
.collect::<Vec<_>>()
.join(", ")
}
fn parse_recipient(transport: Transport, value: &str) -> Result<Recipient, String> {
let value = value.trim();
if value.is_empty() {
return Err("--recipient is empty".to_owned());
}
match transport {
Transport::WebPush => parse_subscription(value).map(Subscription::into_recipient),
Transport::Apns | Transport::Fcm if value.starts_with('{') => Err(format!(
"--recipient looks like a JSON subscription, but --transport is {}, which takes the \
bare token the device registered with. A subscription JSON goes with --transport \
web-push.",
transport_key(transport.platform())
)),
Transport::Apns => Ok(Recipient::apns(value)),
Transport::Fcm => Ok(Recipient::fcm(value)),
}
}
struct Subscription {
endpoint: String,
p256dh: String,
auth: String,
}
impl Subscription {
fn into_recipient(self) -> Recipient {
Recipient::web_push(self.endpoint, self.p256dh, self.auth)
}
}
#[derive(Deserialize)]
struct SubscriptionJson {
endpoint: Option<String>,
keys: Option<SubscriptionKeysJson>,
p256dh: Option<String>,
auth: Option<String>,
}
#[derive(Deserialize)]
struct SubscriptionKeysJson {
p256dh: Option<String>,
auth: Option<String>,
}
fn parse_subscription(json: &str) -> Result<Subscription, String> {
let parsed: SubscriptionJson = serde_json::from_str(json)
.map_err(|err| format!("the subscription is not JSON: {err}. {SUBSCRIPTION_SHAPE}"))?;
let endpoint = parsed.endpoint.ok_or_else(|| missing_field("endpoint"))?;
let (p256dh, auth) = match parsed.keys {
Some(keys) => (
keys.p256dh.ok_or_else(|| missing_field("keys.p256dh"))?,
keys.auth.ok_or_else(|| missing_field("keys.auth"))?,
),
None => (
parsed.p256dh.ok_or_else(|| missing_field("p256dh"))?,
parsed.auth.ok_or_else(|| missing_field("auth"))?,
),
};
Ok(Subscription {
endpoint,
p256dh,
auth,
})
}
fn missing_field(field: &str) -> String {
format!("the subscription JSON has no `{field}`. {SUBSCRIPTION_SHAPE}")
}
const SUBSCRIPTION_SHAPE: &str = "Pass what the browser hands over — \
JSON.stringify(await registration.pushManager.getSubscription()) \
— which is {\"endpoint\":\"…\",\"keys\":{\"p256dh\":\"…\",\
\"auth\":\"…\"}}. The flattened \
{\"endpoint\":…,\"p256dh\":…,\"auth\":…} is accepted too.";
#[derive(Debug)]
pub struct SubscriptionReport {
aud: String,
}
impl SubscriptionReport {
pub fn aud(&self) -> &str {
&self.aud
}
pub fn render(&self) -> String {
format!(
"subscription: valid\n\n \
aud: {aud}\n \
the audience the adapter signs into its VAPID token for this subscription:\n \
the push service's origin, and never the subscription's path. A path-bearing\n \
aud is the commonest cause of a VAPID 401; this one has none.\n \
endpoint: {aud}/…\n \
the path is withheld here: it is a bearer capability — whoever holds it\n \
can push to this browser until the subscription dies.\n \
p256dh: {PUBLIC_KEY_LEN} bytes, an uncompressed P-256 point on the curve\n \
auth: {AUTH_SECRET_LEN} bytes\n",
aud = self.aud
)
}
}
pub fn inspect_subscription(json: &str) -> Result<SubscriptionReport, String> {
let subscription = parse_subscription(json)?;
let aud = origin_of(&subscription.endpoint).map_err(|err| {
format!(
"{err} — a Web Push endpoint is the absolute http(s) URL the browser put in \
`subscription.endpoint`, and its origin is what the VAPID token claims."
)
})?;
SubscriptionKeys::parse(&subscription.p256dh, &subscription.auth)
.map_err(|err| format!("{err} — this subscription cannot be encrypted to as it stands."))?;
Ok(SubscriptionReport { aud })
}
#[cfg(test)]
mod tests {
use super::*;
use cratefield_core::MapConfig;
use cratefield_core::SystemClock;
use cratefield_testing::vectors::{
RFC8291_AUTH_SECRET as AUTH, RFC8291_UA_PUBLIC as P256DH, TEST_P256_PEM,
TEST_VAPID_SUBJECT, WEB_PUSH_ENDPOINT as ENDPOINT, WEB_PUSH_ENDPOINT_CAPABILITY,
web_push_subscription_json,
};
use cratefield_testing::{FakeHttpClient, TempDir};
fn subscription_json() -> String {
web_push_subscription_json()
}
fn send_args(transport: Transport, recipient: &str) -> SendArgs {
SendArgs {
transport,
recipient: recipient.to_owned(),
title: "title".to_owned(),
body: "body".to_owned(),
data: None,
url: None,
ttl: None,
priority: PriorityArg::Immediate,
silent: false,
}
}
#[test]
fn a_generated_key_is_a_usable_p256_key_and_the_public_half_is_65_bytes() {
let dir = TempDir::new("fz-push-keygen-usable");
let path = dir.join("vapid.key");
let generated = vapid_keygen(&KeygenOptions {
file: Some(&path),
force: false,
print_private: false,
})
.expect("generates");
let public = URL_SAFE_NO_PAD
.decode(generated.public_key())
.expect("base64url");
assert_eq!(public.len(), PUBLIC_KEY_LEN, "uncompressed P-256 point");
assert_eq!(public[0], 0x04, "uncompressed point form");
let second = vapid_keygen(&KeygenOptions {
file: Some(&dir.join("second.key")),
force: false,
print_private: false,
})
.expect("generates");
assert_ne!(generated.public_key(), second.public_key());
}
#[test]
fn the_private_key_is_never_in_the_report_unless_it_is_asked_for() {
let generated = vapid_keygen(&KeygenOptions {
file: None,
force: false,
print_private: true,
})
.expect("generates");
let private = generated.private_key.as_str().to_owned();
assert!(
!generated.render().contains(&private),
"the report must not carry the private key"
);
assert!(
!format!("{generated:?}").contains(&private),
"nor must Debug"
);
let disclosure: Zeroizing<String> = generated.private_key_disclosure();
assert!(
disclosure.contains(&private),
"--print-private asks for it explicitly"
);
}
#[test]
fn a_keygen_that_would_discard_the_private_key_is_refused() {
let error = vapid_keygen(&KeygenOptions {
file: None,
force: false,
print_private: false,
})
.expect_err("nothing would keep the key");
assert!(error.contains("--file"), "{error}");
assert!(error.contains("--print-private"), "{error}");
assert!(error.contains(PushKey::VapidPrivateKey.name()), "{error}");
}
#[test]
fn an_existing_key_is_not_overwritten_without_force_and_the_warning_says_why() {
let dir = TempDir::new("fz-push-keygen-force");
let path = dir.join("vapid.key");
let first = vapid_keygen(&KeygenOptions {
file: Some(&path),
force: false,
print_private: false,
})
.expect("generates");
let kept = std::fs::read_to_string(&path).expect("written");
let error = vapid_keygen(&KeygenOptions {
file: Some(&path),
force: false,
print_private: false,
})
.expect_err("refuses to rotate");
assert!(error.contains("--force"), "{error}");
assert!(
error.contains("invalidates every existing browser subscription"),
"{error}"
);
assert_eq!(
std::fs::read_to_string(&path).expect("still there"),
kept,
"the refused run left the key alone"
);
let rotated = vapid_keygen(&KeygenOptions {
file: Some(&path),
force: true,
print_private: false,
})
.expect("rotates with --force");
assert!(rotated.rotated());
let warning = rotated.warning().expect("a rotation warns");
assert!(warning.contains("rotated"), "{warning}");
assert!(
warning.contains("invalidates every existing browser subscription"),
"{warning}"
);
assert!(
!rotated.render().contains("warning"),
"the warning is stderr's, not stdout's: {}",
rotated.render()
);
assert_ne!(rotated.public_key(), first.public_key());
assert_ne!(std::fs::read_to_string(&path).expect("rewritten"), kept);
}
#[test]
#[cfg(unix)]
fn a_force_rotation_that_cannot_complete_leaves_the_old_key_intact() {
use std::os::unix::fs::PermissionsExt as _;
let dir = TempDir::new("fz-push-keygen-torn");
let path = dir.join("vapid.key");
vapid_keygen(&KeygenOptions {
file: Some(&path),
force: false,
print_private: false,
})
.expect("the venture's key");
let kept = std::fs::read_to_string(&path).expect("written");
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500))
.expect("makes the directory read-only");
let staged = std::fs::write(dir.join("probe"), "x").is_err();
let outcome = if staged {
Some(vapid_keygen(&KeygenOptions {
file: Some(&path),
force: true,
print_private: false,
}))
} else {
None
};
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))
.expect("restores the directory");
let Some(outcome) = outcome else {
eprintln!("skipped: this process writes into a read-only directory (root?)");
return;
};
let Err(error) = outcome else {
panic!("the rotation could not be completed, so it must have refused");
};
assert!(
error.contains("untouched"),
"the refusal says the key survived: {error}"
);
assert_eq!(
std::fs::read_to_string(&path).expect("the old key is still there"),
kept,
"a rotation that could not be completed destroyed the venture's only key"
);
}
#[test]
fn a_subscription_is_read_in_both_shapes_and_its_aud_is_the_origin() {
let nested = inspect_subscription(&subscription_json()).expect("valid");
let flat = inspect_subscription(&format!(
"{{\"endpoint\":\"{ENDPOINT}\",\"p256dh\":\"{P256DH}\",\"auth\":\"{AUTH}\"}}"
))
.expect("valid");
assert_eq!(nested.aud(), flat.aud());
assert_eq!(nested.aud(), "https://updates.push.services.mozilla.com");
assert!(
!nested.render().contains(WEB_PUSH_ENDPOINT_CAPABILITY),
"{}",
nested.render()
);
}
#[test]
fn a_subscription_with_a_wrong_length_key_is_refused_by_name() {
let short_p256dh = &P256DH[..P256DH.len() - 2];
let error = inspect_subscription(&format!(
"{{\"endpoint\":\"{ENDPOINT}\",\"keys\":{{\"p256dh\":\"{short_p256dh}\",\"auth\":\"{AUTH}\"}}}}"
))
.expect_err("wrong length");
assert!(error.contains("p256dh"), "{error}");
let error = inspect_subscription(&format!(
"{{\"endpoint\":\"{ENDPOINT}\",\"keys\":{{\"p256dh\":\"{P256DH}\",\"auth\":\"c2hvcnQ\"}}}}"
))
.expect_err("wrong length");
assert!(error.contains("auth"), "{error}");
}
#[test]
fn an_endpoint_that_is_not_an_absolute_http_url_is_refused() {
for endpoint in ["/relative/path", "ftp://push.example/x"] {
let error = inspect_subscription(&format!(
"{{\"endpoint\":\"{endpoint}\",\"keys\":{{\"p256dh\":\"{P256DH}\",\"auth\":\"{AUTH}\"}}}}"
))
.expect_err("no origin");
assert!(error.contains("endpoint"), "{endpoint}: {error}");
}
}
#[test]
fn a_bare_token_and_a_subscription_are_not_interchangeable() {
let Err(error) = SendRequest::from_args(&send_args(Transport::Apns, &subscription_json()))
else {
panic!("a subscription is not a device token");
};
assert!(error.contains("web-push"), "{error}");
let request = SendRequest::from_args(&send_args(Transport::Apns, " DEVICE-TOKEN "))
.expect("a device token");
assert_eq!(request.transport(), Platform::Ios);
let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
.expect("a subscription");
assert_eq!(request.transport(), Platform::Web);
}
#[test]
fn an_unrouted_transport_reports_why_nothing_was_sent() {
let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
.expect("parses");
let report = plan(
&MapConfig::from_pairs(Vec::<(String, String)>::new()),
&request,
);
assert!(!report.ok());
let rendered = report.render();
assert!(rendered.contains("NOT SENT"), "{rendered}");
assert!(
rendered.contains(PushKey::VapidPrivateKey.name()),
"{rendered}"
);
assert!(
rendered.contains(PushKey::VapidSubject.name()),
"{rendered}"
);
let half = MapConfig::from_pairs([(
PushKey::VapidSubject.name().to_owned(),
"mailto:ops@example.test".to_owned(),
)]);
let rendered = plan(&half, &request).render();
assert!(rendered.contains("half-wired"), "{rendered}");
assert!(
rendered.contains(PushKey::VapidPrivateKey.name()),
"{rendered}"
);
}
#[test]
fn a_dry_run_of_a_configured_transport_says_it_would_have_sent() {
let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
.expect("parses");
let report = plan(&web_push_config(), &request);
assert!(report.ok(), "{}", report.render());
assert!(report.render().contains("--dry-run"), "{}", report.render());
}
#[test]
fn a_configured_transport_sends_and_reports_the_outcome() {
let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
.expect("parses");
let http: Arc<dyn HttpClient> = Arc::new(FakeHttpClient::ok_json(""));
let clock: Arc<dyn Clock> = Arc::new(SystemClock);
let report = pollster::block_on(send(&web_push_config(), &http, &clock, &request));
assert!(report.ok(), "{}", report.render());
assert!(report.render().contains("DELIVERED"), "{}", report.render());
}
#[test]
fn the_report_never_prints_a_value_out_of_the_environment() {
let sentinel = |var: &PushVar| format!("SENTINEL-{}-VALUE", var.name());
let config = MapConfig::from_pairs(
cratefield_push_wiring::PUSH_ENV
.iter()
.map(|var| (var.name().to_owned(), sentinel(var))),
);
let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
.expect("parses");
let rendered = plan(&config, &request).render();
for var in cratefield_push_wiring::PUSH_ENV {
assert!(
!rendered.contains(&sentinel(var)),
"{} leaked into the report:\n{rendered}",
var.name()
);
}
assert!(
!rendered.contains(AUTH),
"the auth secret leaked:\n{rendered}"
);
assert!(
!rendered.contains(WEB_PUSH_ENDPOINT_CAPABILITY),
"the endpoint leaked:\n{rendered}"
);
}
#[test]
fn the_failure_path_never_prints_the_recipient() {
const DEVICE_TOKEN: &str =
"0a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff";
let leaky = |target: &str| {
let http: Arc<dyn HttpClient> = Arc::new(FakeHttpClient::scripted(vec![Err(
cratefield_core::HttpError::Transport(format!(
"error sending request for url ({target})"
)),
)]));
http
};
let clock: Arc<dyn Clock> = Arc::new(SystemClock);
let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
.expect("parses");
let report =
pollster::block_on(send(&web_push_config(), &leaky(ENDPOINT), &clock, &request));
let rendered = report.render();
assert!(!report.ok(), "{rendered}");
assert!(rendered.contains("FAILED"), "{rendered}");
assert!(
!rendered.contains(WEB_PUSH_ENDPOINT_CAPABILITY),
"the subscription's bearer capability leaked through the failure:\n{rendered}"
);
assert!(
!rendered.contains(ENDPOINT),
"the endpoint leaked through the failure:\n{rendered}"
);
assert!(rendered.contains("retryable"), "{rendered}");
let request = SendRequest::from_args(&send_args(Transport::Apns, DEVICE_TOKEN))
.expect("a device token");
let report = pollster::block_on(send(
&apns_config(),
&leaky(&format!(
"https://api.push.apple.com/3/device/{DEVICE_TOKEN}"
)),
&clock,
&request,
));
let rendered = report.render();
assert!(rendered.contains("FAILED"), "{rendered}");
assert!(
!rendered.contains(DEVICE_TOKEN),
"the device token leaked through the failure:\n{rendered}"
);
}
#[test]
fn unregistered_advice_is_the_transports_own() {
let web = advice(&PushError::Unregistered, Platform::Web);
assert!(
web.contains("delete it from the venture's registry"),
"{web}"
);
assert!(web.contains("browser"), "{web}");
for platform in [Platform::Ios, Platform::Android] {
let device = advice(&PushError::Unregistered, platform);
assert!(
device.contains("delete it from the venture's registry"),
"{platform:?}: {device}"
);
assert!(!device.contains("browser"), "{platform:?}: {device}");
assert!(!device.contains("Web Push"), "{platform:?}: {device}");
}
}
#[test]
fn data_has_to_be_a_json_object() {
for raw in ["[1,2]", "\"a string\"", "42", "null", "true"] {
let mut args = send_args(Transport::WebPush, &subscription_json());
args.data = Some(raw.to_owned());
let Err(error) = SendRequest::from_args(&args) else {
panic!("{raw} is not a JSON object");
};
assert!(error.contains("JSON object"), "{raw}: {error}");
}
let mut args = send_args(Transport::WebPush, &subscription_json());
args.data = Some("{\"order\":\"A-17\"}".to_owned());
SendRequest::from_args(&args).expect("an object is what the flag is for");
let mut args = send_args(Transport::WebPush, &subscription_json());
args.data = Some("{oops".to_owned());
let Err(error) = SendRequest::from_args(&args) else {
panic!("`{{oops` is not JSON");
};
assert!(error.contains("not JSON"), "{error}");
}
#[test]
fn the_set_them_hint_names_only_variables_that_have_to_be_set() {
let request = SendRequest::from_args(&send_args(Transport::Apns, " DEVICE-TOKEN "))
.expect("a device token");
let rendered = plan(
&MapConfig::from_pairs(Vec::<(String, String)>::new()),
&request,
)
.render();
assert!(rendered.contains("Set them"), "{rendered}");
for var in vars_for(Platform::Ios) {
assert_eq!(
rendered.contains(var.name()),
var.required,
"{} is required={} but {} in the hint:\n{rendered}",
var.name(),
var.required,
if var.required {
"missing from"
} else {
"named in"
}
);
}
}
#[test]
#[cfg(not(feature = "push-send"))]
fn the_refusal_points_at_an_installed_binary_not_at_a_venture_dependency() {
let request = SendRequest::from_args(&send_args(Transport::WebPush, &subscription_json()))
.expect("parses");
let Err(error) = send_now(&web_push_config(), &request) else {
panic!("no client, no send");
};
assert!(
error.contains("cargo install cratefield-cli --features push-send"),
"{error}"
);
assert!(
!error.contains("cratefield-cli = {"),
"the refusal must not hand out a dependency recipe: {error}"
);
assert!(error.contains("--dry-run"), "{error}");
}
#[test]
#[cfg(feature = "push-send")]
fn the_send_stack_is_built_on_a_clock_that_can_actually_time_out() {
let (_http, clock) = send_stack();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a runtime");
let outcome = runtime.block_on(async move {
cratefield_core::timeout(
&*clock,
async {
tokio::time::sleep(Duration::from_secs(5)).await;
"the upstream answered eventually"
},
Duration::from_millis(10),
)
.await
});
assert!(
outcome.is_none(),
"the deadline did not fire: this clock runs the future to completion, so every \
bound `BoundedHttpClient` puts on a send is decoration"
);
}
fn web_push_config() -> MapConfig {
MapConfig::from_pairs([
(
PushKey::VapidPrivateKey.name().to_owned(),
TEST_P256_PEM.to_owned(),
),
(
PushKey::VapidSubject.name().to_owned(),
TEST_VAPID_SUBJECT.to_owned(),
),
])
}
fn apns_config() -> MapConfig {
MapConfig::from_pairs([
(
PushKey::ApnsKeyP8.name().to_owned(),
TEST_P256_PEM.to_owned(),
),
(
PushKey::ApnsKeyId.name().to_owned(),
"ABCDE12345".to_owned(),
),
(
PushKey::ApnsTeamId.name().to_owned(),
"TEAM123456".to_owned(),
),
(
PushKey::ApnsTopic.name().to_owned(),
"ventures.factory0.example".to_owned(),
),
])
}
}