sipx-cli 1.0.0-rc.2

sipx — a command line SIP softphone
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
//! `sipx register`.

use std::time::Duration;

use sipx_sip::push::Device;
use sipx_sip::{Host, HostName, Uri};
use sipx_transport::{Config as TransportConfig, Target, TransportKind, bind};
use sipx_ua::{Config, Credentials, Flow, InstanceId, RegId, UserAgent};

use crate::Args;
use crate::output::{Exit, Format, Report, fail};

pub(crate) const HELP: &str = "\
sipx register — register with a registrar

USAGE:
    sipx register <AOR> [OPTIONS]

ARGS:
    <AOR>    The address of record, e.g. sip:alice@example.com

OPTIONS:
    --password <P>       Password. Prefer SIPX_PASSWORD, since argv is world-readable.
    --target <ADDR>      Where to send, if not derived from the AOR (host:port)
    --expires <S>        Lease to ask for, in seconds (default 3600)
    --local <ADDR>       Local address to bind (default 0.0.0.0:0)
    --transport <T>      Signalling: udp, tcp, tls, ws or wss (default udp)
    --tcp                Legacy alias for --transport tcp
    --tls-server-name <N> Certificate identity to verify (default AOR domain)
    --tls-ca <FILE>      Add PEM trust roots to the platform store
    --tls-cert <FILE>    Client certificate chain for mutual TLS (with --tls-key)
    --tls-key <FILE>     Client private key for mutual TLS (with --tls-cert)
    --header <H>         Add an application-owned REGISTER field; repeat 'Name: value'
    --keep-alive         Keep refreshing until interrupted
    --outbound           Register as one Outbound flow (RFC 5626), and report whether the
                         registrar accepted it
    --instance <URN>     With --outbound: this device identity rather than a fresh one
    --push-provider <P>  Push notification service this device can be woken through (RFC 8599)
    --push-prid <T>      The identifier that service knows this device by
    --push-param <X>     Service-specific extra, when the service needs one
    --wake               Act as though a push arrived: refresh the binding and report the PURR
    --capture <FILE>     Record signalling to this pcapng file. Credentials are redacted;
                         TLS is recorded decrypted. Still identifies who called whom
    --counters <FILE>    Write this run's signalling counters to this file, as JSON.
                         Implied by --capture, as <capture>.counters.json
    --json               Report as JSON
";

#[allow(
    clippy::too_many_lines,
    reason = "the command lifecycle is kept in execution order so validation-before-I/O remains auditable"
)]
pub(crate) async fn run(raw: &[String], format: Format) -> Exit {
    // Before the address of record, and before any socket: a valued flag that was given no value
    // cannot be honoured, and reading it as absent is what let `--instance` register a device
    // identity nobody chose (`S-30`).
    let args = match crate::arguments(raw, HELP, format) {
        Ok(args) => args,
        Err(exit) => return exit,
    };

    let Some(aor) = args.positional() else {
        eprint!("{HELP}");
        return fail(format, Exit::Usage, "an address of record is required");
    };
    let headers = match crate::header::from_args(&args) {
        Ok(headers) => headers,
        Err(message) => return fail(format, Exit::Usage, &message),
    };

    let Ok(parsed_aor) = Uri::parse(bytes::Bytes::from(aor.to_owned())) else {
        return fail(
            format,
            Exit::Usage,
            &format!("not a SIP address of record: {aor}"),
        );
    };

    let Some((user, domain)) = parse_aor(aor) else {
        return fail(
            format,
            Exit::Usage,
            &format!("not a SIP address of record: {aor}"),
        );
    };

    // A password on the command line is visible to every process on the machine, so the
    // environment is the documented route and the flag is the convenience.
    let password = args
        .value("password")
        .map(str::to_owned)
        .or_else(|| std::env::var("SIPX_PASSWORD").ok());

    // Validated before any socket is opened, and every combination that cannot work is refused:
    // a flag that parses and is dropped is worse than one that errors (`P-7` was filed for
    // exactly that shape), so `--wake` without a push service, half a push pair, or an
    // `--instance` with no flow to name are usage errors here, never surprises later.
    let reach = match reachability(&args) {
        Ok(reach) => reach,
        Err(message) => return fail(format, Exit::Usage, &message),
    };

    let transport =
        match crate::signalling::Selection::from_args(&args, parsed_aor.scheme().is_secure()) {
            Ok(transport) => transport,
            Err(message) => return fail(format, Exit::Usage, &message),
        };

    let unresolved = match resolve_target(args.value("target"), &domain, transport.kind()) {
        Ok(target) => target,
        Err(message) => return fail(format, Exit::Usage, &message),
    };
    let target = match transport.target(&args, unresolved.addr, &domain) {
        Ok(target) => target,
        Err(message) => return fail(format, Exit::Usage, &message),
    };
    let negotiated_transport = target.transport;

    let local = args.value("local").unwrap_or("0.0.0.0:0");
    let Ok(local) = local.parse() else {
        return fail(format, Exit::Usage, &format!("not an address: {local}"));
    };

    let mut config = TransportConfig::new(local);
    // The `Via` sent-by names where *this* client expects responses (RFC 3261 §18.1.1); the
    // bound address may be 0.0.0.0, which names every interface and reaches none.
    config.sent_by = crate::advertise::reachable_ip(local, target.addr.ip()).to_string();
    crate::apply_capture(&args, &mut config);
    if let Err(message) = transport.configure_client(&args, &mut config) {
        return fail(format, Exit::Usage, &message);
    }
    let (handle, _incoming) = match bind(config).await {
        Ok(bound) => bound,
        Err(error) => return fail(format, Exit::Failed, &format!("bind: {error}")),
    };

    // Armed here, not at the end: the run that most needs the numbers is the one that fails, and
    // every `return fail(…)` below now takes the counters file with it.
    let export = crate::counters::Export::arm(&args, &handle);
    // The registrar stores this binding and routes every call to the address-of-record at it
    // (RFC 3261 §10.2.6), so it carries the advertised address — reachable host, real port —
    // not the bound one.
    let contact = format!("<sip:{user}@{}>", handle.advertised());

    let Ok(host) = HostName::new(domain.clone()) else {
        return fail(format, Exit::Usage, &format!("not a hostname: {domain}"));
    };
    let registrar = Uri::sip(Host::Name(host));

    let mut ua_config = Config::new(format!("<sip:{user}@{domain}>"), contact, registrar, target);
    for header in headers {
        ua_config = ua_config.with_header(header);
    }
    ua_config.expires = Duration::from_secs(args.number("expires").unwrap_or(3600));
    if let Some(password) = password {
        ua_config = ua_config.with_credentials(Credentials::new(user.clone(), password));
    }

    let Reachability { flow, device, wake } = reach;
    let outbound = flow.is_some();
    let provider = device.as_ref().map(|device| device.provider().to_owned());
    if let Some(flow) = flow {
        ua_config = ua_config.with_outbound(flow);
    }
    if let Some(device) = device {
        ua_config = ua_config.with_push(device);
    }

    let mut agent = UserAgent::new(handle, ua_config);

    // Both reports name the address of record the same way: the normalised AOR, not the argument
    // as typed, so a caller matching on it does not have to.
    let aor = format!("sip:{user}@{domain}");

    match agent.register().await {
        Ok(lease) => {
            let mut report = transport.report(
                Report::new()
                    .text("status", "registered")
                    .text("aor", aor.clone())
                    .seconds("expires", lease.granted)
                    .seconds("refresh_in", lease.refresh_after),
                negotiated_transport,
            );
            if outbound {
                // §6: a registrar that performed an outbound registration says so in `Require`.
                // Asking and not getting it is not an error — the binding is an ordinary one —
                // but it must be *said*, or a script believes a flow is being kept that is not.
                report = report.boolean("flow", agent.flow_accepted());
            }
            if let Some(provider) = &provider {
                // §8.2: a 200 from a registrar that named a *different* push service is a
                // binding nothing will ever wake, and it looks exactly like success from
                // everywhere except this field.
                report = report.boolean("push", agent.push_support().supports(provider));
            }
            report = match export.into_report(report) {
                Ok(report) => report,
                Err(message) => return fail(format, Exit::Failed, &message),
            };
            report.emit(format);

            if wake {
                // §4.1.3: a push is answered with a binding-refresh REGISTER, and only then is
                // there a flow for the request the push was sent for. `woken` is that ordering;
                // when to call it is the application's, and this flag is the CLI acting as its
                // own push service for one ring.
                if let Err(exit) = report_wake(&mut agent, format, &aor).await {
                    return exit;
                }
            }

            if args.flag("keep-alive") {
                // `keep_registered` refreshes forever; its success type is uninhabited, so the
                // only way out is a failure. The single-arm match says so, where an `if let`
                // would read as though the other case were possible.
                let Err(error) = agent.keep_registered().await;
                return report_failure(format, &error);
            }
            Exit::Success
        }
        Err(error) => report_failure(format, &error),
    }
}

/// What the reachability flags asked for: an Outbound flow (RFC 5626), a push service to be
/// woken through (RFC 8599), and whether to act as though a push arrived once registered.
///
/// Validated as a whole rather than flag by flag, because the mistakes are *combinations* —
/// half a push pair, a wake with nothing to wake through, an instance identity with no flow —
/// and each one left to pass silently would send a registration that means less than the person
/// typing it believes.
#[derive(Debug)]
struct Reachability {
    /// `--outbound`: the flow this registration is (§4.2), with the device identity it presents.
    flow: Option<Flow>,
    /// The push parameters for the `Contact` URI (§4.1.2), from the `--push-*` flags.
    device: Option<Device>,
    /// `--wake`: after registering, send §4.1.3's binding-refresh REGISTER and report the PURR.
    wake: bool,
}

/// Read the reachability flags, refusing every combination that would otherwise parse and be
/// dropped.
fn reachability(args: &Args<'_>) -> Result<Reachability, String> {
    let provider = args.value("push-provider");
    let prid = args.value("push-prid");
    let param = args.value("push-param");

    // §4.1.2 has a UA insert `pn-provider` and `pn-prid` together: either alone names no service
    // that could be asked to wake anything, so half a pair is refused rather than registered.
    let device = match (provider, prid) {
        (Some(provider), Some(prid)) => {
            let device = Device::new(provider, prid)
                .map_err(|error| format!("--push-provider/--push-prid: {error}"))?;
            let device = match param {
                Some(param) => device
                    .with_param(param)
                    .map_err(|error| format!("--push-param: {error}"))?,
                None => device,
            };
            Some(device)
        }
        (None, None) => {
            if param.is_some() {
                return Err(
                    "--push-param belongs with --push-provider and --push-prid: without them \
                     there is no push service for it to describe"
                        .to_owned(),
                );
            }
            None
        }
        _ => {
            return Err(
                "--push-provider and --push-prid belong together: RFC 8599 §4.1.2 has a UA name \
                 the service and the device in one breath, and either alone wakes nothing"
                    .to_owned(),
            );
        }
    };

    let wake = args.flag("wake");
    if wake && device.is_none() {
        return Err(
            "--wake acts as though a push notification arrived, and no push service was named: \
             give --push-provider and --push-prid with it"
                .to_owned(),
        );
    }

    let instance = args.value("instance");
    let flow = if args.flag("outbound") {
        let instance = match instance {
            // §4.1 wants the identity persistent across restarts. The CLI keeps no state, so the
            // default is a fresh one and the flag is how a caller supplies the one it persisted.
            Some(urn) => InstanceId::parse(urn).ok_or_else(|| {
                format!("--instance must be a URN — RFC 5626 §4.1's grammar is `instance-val = urn`: {urn}")
            })?,
            None => InstanceId::generate(),
        };
        // One flow per invocation, so it is flow 1: §4.2 numbers flows stably, which is what
        // makes a refresh replace this binding rather than add a second one beside it.
        let reg_id = RegId::new(1)
            .ok_or_else(|| "reg-id 1 is inside §4.2's range by construction".to_owned())?;
        Some(Flow { instance, reg_id })
    } else {
        if instance.is_some() {
            return Err(
                "--instance names the device an Outbound flow registers; without --outbound \
                 there is no flow to name it for"
                    .to_owned(),
            );
        }
        None
    };

    Ok(Reachability { flow, device, wake })
}

/// Drive RFC 8599 §4.1.3's binding-refresh REGISTER and report the lease it returned.
///
/// A wake is reported as its own line rather than as fields on the registration's, because it is
/// a second exchange with the registrar: what was true after registering stays true, and a caller
/// reading the stream sees both answers instead of one overwritten by the other.
async fn report_wake(agent: &mut UserAgent, format: Format, aor: &str) -> Result<(), Exit> {
    match agent.woken().await {
        Ok(pending) => {
            let mut report = Report::new()
                .text("status", "woken")
                .text("aor", aor)
                .seconds("expires", pending.lease.granted)
                .seconds("refresh_in", pending.lease.refresh_after);
            // §4.2: the PURR is the registrar's, assigned when it has one to give. Absent is the
            // ordinary case, so the field appears only when it was actually handed out.
            if let Some(purr) = &pending.purr {
                report = report.text("purr", purr);
            }
            report.emit(format);
            Ok(())
        }
        Err(error) => Err(report_failure(format, &error)),
    }
}

fn report_failure(format: Format, error: &sipx_ua::Error) -> Exit {
    let exit = match error {
        sipx_ua::Error::Rejected { status, .. } => Exit::for_status(*status),
        // A 555 is a refusal by the far end (RFC 8599 §8.1), so it exits the way every other
        // refusal does — `Exit::for_status(555)` is `Rejected` too, and a status code that
        // changed the exit code by being modelled more precisely would be a change to this
        // CLI's documented interface rather than a change to what happened. What the caller
        // gains is the message, which names the refusal instead of numbering it.
        sipx_ua::Error::PushNotSupported { .. } => Exit::Rejected,
        sipx_ua::Error::AuthenticationFailed | sipx_ua::Error::CredentialsRequired => {
            Exit::Unauthorized
        }
        sipx_ua::Error::NoResponse => Exit::Timeout,
        _ => Exit::Failed,
    };
    fail(format, exit, &error.to_string())
}

/// Split `sip:user@domain` into its two halves.
pub(crate) fn parse_aor(aor: &str) -> Option<(String, String)> {
    let rest = aor
        .strip_prefix("sip:")
        .or_else(|| aor.strip_prefix("sips:"))?;
    let (user, domain) = rest.split_once('@')?;
    (!user.is_empty() && !domain.is_empty()).then(|| {
        (
            user.to_owned(),
            domain.split(';').next().unwrap_or(domain).to_owned(),
        )
    })
}

/// Where to send. An explicit `--target` wins; otherwise the domain must already be an address,
/// since resolving a name is the resolver's job and this command does not carry one.
pub(crate) fn resolve_target(
    explicit: Option<&str>,
    domain: &str,
    transport: TransportKind,
) -> Result<Target, String> {
    let raw = explicit.unwrap_or(domain);
    if let Ok(addr) = raw.parse::<std::net::SocketAddr>() {
        return Ok(Target::new(addr, transport));
    }
    if let Ok(ip) = raw.parse::<std::net::IpAddr>() {
        return Ok(Target::new(
            std::net::SocketAddr::new(ip, transport.default_port()),
            transport,
        ));
    }
    Err(format!(
        "cannot reach {raw}: give --target host:port, since name resolution is not wired into \
         this command yet"
    ))
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
    use super::*;

    #[test]
    fn an_address_of_record_splits_into_user_and_domain() {
        assert_eq!(
            parse_aor("sip:alice@example.com"),
            Some(("alice".to_owned(), "example.com".to_owned()))
        );
        assert_eq!(
            parse_aor("sips:bob@secure.example"),
            Some(("bob".to_owned(), "secure.example".to_owned()))
        );
    }

    /// Parameters belong to the URI, not the domain. Leaving them on makes the registrar name
    /// `example.com;transport=tcp`, which resolves to nothing.
    /// `S-27`, the half that costs more than `dial`'s: a challenged registration sends a digest
    /// credential, so a silent downgrade to UDP puts it on the wire in the clear. The first argument
    /// must be the subcommand — `Args::positional` skips index 0.
    #[tokio::test]
    async fn a_sips_aor_is_refused_rather_than_registered_in_the_clear() {
        let exit = run(
            &["register".to_owned(), "sips:bob@192.0.2.1".to_owned()],
            Format::Text,
        )
        .await;
        assert_eq!(
            exit.code(),
            Exit::Usage.code(),
            "registering a sips: AOR must be refused, not sent in the clear"
        );
    }

    #[test]
    fn uri_parameters_are_not_part_of_the_domain() {
        assert_eq!(
            parse_aor("sip:alice@example.com;transport=tcp"),
            Some(("alice".to_owned(), "example.com".to_owned()))
        );
    }

    #[test]
    fn something_that_is_not_an_aor_is_refused() {
        for bad in [
            "alice@example.com",
            "sip:example.com",
            "sip:@example.com",
            "sip:alice@",
            "",
        ] {
            assert!(parse_aor(bad).is_none(), "{bad} is not an AOR");
        }
    }

    #[test]
    fn an_explicit_target_wins_over_the_domain() {
        let target = resolve_target(Some("192.0.2.1:5080"), "example.com", TransportKind::Udp)
            .expect("an address");
        assert_eq!(target.addr.to_string(), "192.0.2.1:5080");
    }

    #[test]
    fn a_bare_address_gets_the_transport_default_port() {
        assert_eq!(
            resolve_target(Some("192.0.2.1"), "x", TransportKind::Udp)
                .expect("an address")
                .addr
                .port(),
            5060
        );
        assert_eq!(
            resolve_target(Some("192.0.2.1"), "x", TransportKind::Tls)
                .expect("an address")
                .addr
                .port(),
            5061
        );
    }

    /// A name with nothing to resolve it says so, rather than failing later with something
    /// that looks like a network problem.
    #[test]
    fn a_name_with_no_resolver_is_a_named_usage_error() {
        let error =
            resolve_target(None, "example.com", TransportKind::Udp).expect_err("cannot be reached");
        assert!(error.contains("--target"), "{error}");
    }

    fn parsed(items: &[&str]) -> Result<Reachability, String> {
        let raw: Vec<String> = items.iter().map(|item| (*item).to_owned()).collect();
        let args = Args::new(&raw).expect("a well formed argument list");
        reachability(&args)
    }

    /// The flags build the config they select: the flow, the push parameters, and the wake.
    #[test]
    fn the_reachability_flags_build_what_they_select() {
        let reach = parsed(&[
            "register",
            "sip:alice@example.com",
            "--outbound",
            "--push-provider",
            "webpush",
            "--push-prid",
            "c1a5b3e7d9f2",
            "--push-param",
            "7f3ad0",
            "--wake",
        ])
        .expect("a valid combination");

        let flow = reach.flow.expect("a flow was asked for");
        assert_eq!(flow.reg_id.value(), 1, "one invocation is one flow");
        assert!(flow.instance.urn().starts_with("urn:uuid:"));
        let device = reach.device.expect("a push service was named");
        assert_eq!(device.provider(), "webpush");
        assert_eq!(device.prid(), "c1a5b3e7d9f2");
        assert_eq!(device.param(), Some("7f3ad0"));
        assert!(reach.wake);
    }

    /// A persisted device identity is presented verbatim — that is the whole of §4.1's
    /// persistence rule, and the reason the flag exists.
    #[test]
    fn an_adopted_instance_identity_is_presented_verbatim() {
        let reach = parsed(&[
            "register",
            "sip:alice@example.com",
            "--outbound",
            "--instance",
            "urn:uuid:00000000-0000-4000-8000-0000000000ab",
        ])
        .expect("a valid combination");
        let flow = reach.flow.expect("a flow");
        assert_eq!(
            flow.instance.urn(),
            "urn:uuid:00000000-0000-4000-8000-0000000000ab"
        );
    }

    /// §4.1.2 inserts `pn-provider` and `pn-prid` together; either alone names no service
    /// that could be asked to wake anything.
    #[test]
    fn half_a_push_pair_is_refused() {
        for items in [
            &["register", "sip:a@b.c", "--push-provider", "webpush"][..],
            &["register", "sip:a@b.c", "--push-prid", "tok"][..],
        ] {
            let error = parsed(items).expect_err("half a pair is not a push service");
            assert!(error.contains("--push-provider"), "{error}");
            assert!(error.contains("--push-prid"), "{error}");
        }
    }

    #[test]
    fn a_push_param_without_the_pair_is_refused() {
        let error = parsed(&["register", "sip:a@b.c", "--push-param", "x"])
            .expect_err("a param with no service to describe");
        assert!(error.contains("--push-provider"), "{error}");
    }

    /// A wake with no push service named is the accepted-and-dropped shape: the flag would
    /// parse, and nothing would ever call `woken`.
    #[test]
    fn a_wake_without_a_push_service_is_refused() {
        let error =
            parsed(&["register", "sip:a@b.c", "--wake"]).expect_err("nothing to be woken through");
        assert!(error.contains("--push-provider"), "{error}");
    }

    #[test]
    fn an_instance_without_outbound_is_refused() {
        let error = parsed(&["register", "sip:a@b.c", "--instance", "urn:uuid:x"])
            .expect_err("no flow to name the device for");
        assert!(error.contains("--outbound"), "{error}");
    }

    #[test]
    fn an_instance_that_is_not_a_urn_is_refused() {
        let error = parsed(&[
            "register",
            "sip:a@b.c",
            "--outbound",
            "--instance",
            "device-7",
        ])
        .expect_err("§4.1's grammar is instance-val = urn");
        assert!(error.contains("--instance"), "{error}");
    }

    /// A `pn-prid` that RFC 3261's `pvalue` cannot hold would not be rejected by the
    /// registrar — it would silently become a different URI. Refused here instead.
    #[test]
    fn a_prid_that_a_uri_parameter_cannot_hold_is_refused() {
        let error = parsed(&[
            "register",
            "sip:a@b.c",
            "--push-provider",
            "webpush",
            "--push-prid",
            "tok;en",
        ])
        .expect_err("a ';' would start another parameter at the far end");
        assert!(error.contains("--push-prid"), "{error}");
    }

    /// The refusal is the command's answer, at the command's exit code — a usage error
    /// before any socket is opened, not a registration that quietly means less.
    #[tokio::test]
    async fn a_wake_without_push_is_a_usage_error_from_the_command() {
        let exit = run(
            &[
                "register".to_owned(),
                "sip:alice@example.com".to_owned(),
                "--wake".to_owned(),
            ],
            Format::Text,
        )
        .await;
        assert_eq!(exit.code(), Exit::Usage.code());
    }
}