aranet_core/
bluez_agent.rs

1//! Pairing Aranet sensors with BlueZ on Linux.
2//!
3//! Before `aranet-core` connects to a sensor on Linux, it pairs the sensor if
4//! BlueZ doesn't list it as paired. Otherwise BlueZ asks for pairing by itself
5//! on every connection: its battery plugin reads the sensor's Battery Level,
6//! which needs encryption. That request needs an agent. With none registered,
7//! as on a headless host, BlueZ refuses it and the connection's reads can stall
8//! until they time out; a desktop's agent shows a pairing dialog instead. Each
9//! pairing is one short session:
10//!
11//! 1. If the sensor refused to pair less than 10 minutes ago in this process
12//!    (`AuthenticationFailed` or `AuthenticationRejected`, as when it wants a
13//!    PIN), skip it and connect without pairing.
14//! 2. On aranet-core's own runtime, open a private connection to the system bus
15//!    and read the sensor's `org.bluez.Device1.Paired` property. A paired
16//!    sensor ends the session.
17//! 3. Register a `NoInputNoOutput` agent at `/dev/rye/aranet/agent` on that
18//!    connection (`org.bluez.AgentManager1.RegisterAgent`).
19//! 4. Call `org.bluez.Device1.Pair` on the same connection. It gets as long as
20//!    a connect that waits for BlueZ's service discovery would. If it runs out
21//!    while BlueZ still lists the sensor as not paired, `CancelPairing` stops
22//!    it. If BlueZ doesn't answer whether it is paired, closing the connection
23//!    stops a pairing that is still running.
24//! 5. Unregister the agent and close the connection.
25//!
26//! When BlueZ connects the sensor for `Pair`, it answers `Pair` only after its
27//! service discovery has finished, which can take an Aranet4 about 20 s, but it
28//! lists the sensor as paired as soon as the bond exists. A `Pair` that runs
29//! out of time after that counts as paired and isn't cancelled, which would
30//! remove the new bond. The connect goes ahead, and the session keeps its
31//! connection open, with the agent unregistered, until BlueZ answers `Pair`:
32//! closing it earlier would cancel that discovery.
33//!
34//! BlueZ sends the agent requests of a `Pair` call to the agent that the
35//! calling connection registered. On a host with no other agent, BlueZ also
36//! makes the session's agent the default one while it is registered, so the
37//! agent approves requests only for the object path of the sensor being paired
38//! and rejects the rest. It always rejects `AuthorizeService`, and
39//! `RequestPasskey`, which would need a keyboard. Closing the connection
40//! removes the agent and cancels an unfinished pairing, even when the session
41//! is cut short.
42//!
43//! aranet never asks to be BlueZ's default agent, and it keeps no agent
44//! registered between connects. Since BlueZ 5.51 the first agent to register
45//! becomes the default one, so a long-lived agent on a host without a desktop
46//! agent would receive the pairing requests of every nearby device. Scans, and
47//! sensors that are already paired, never register an agent.
48//!
49//! A pairing that fails is logged as a warning, and the connect goes ahead
50//! unpaired, with the stall or the dialog described above. The next connect
51//! pairs again, unless the sensor refused.
52//!
53//! The session's agent has no display or keyboard, so it can't pair a sensor
54//! that asks for its PIN. Some such sensors don't refuse (an Aranet2 with
55//! BlueZ 5.82, for example): the pairing finishes without the PIN, then the
56//! sensor rejects that bond at every later connect, so its reads fail while
57//! BlueZ lists it as paired, and a new bond made the same way fails the same
58//! way. Pair such a sensor once by hand, with its PIN; aranet then uses that
59//! bond. To do that, stop every aranet process that uses it (a connect during
60//! the pairing cancels it) and run `bluetoothctl remove <MAC>`. Then run
61//! `bluetoothctl` and, at its prompt, `scan on` until the sensor is listed,
62//! `scan off` and `pair <MAC>`, entering the PIN when asked. A one-line
63//! `bluetoothctl pair <MAC>` registers no agent, so on a host without a
64//! desktop agent it can't ask for the PIN. A sensor that has lost its bond
65//! with this computer (after a reset, for example) is still listed as paired,
66//! and its encrypted reads fail too: remove the stale bond with
67//! `bluetoothctl remove <MAC>`. The next connect then pairs it again, unless
68//! it asks for its PIN; then pair it by hand as above.
69
70use std::sync::{Arc, LazyLock, Mutex, PoisonError};
71use std::time::Duration;
72
73use btleplug::api::Peripheral as _;
74use btleplug::platform::Peripheral;
75use dbus::channel::MatchingReceiver;
76use dbus::message::MatchRule;
77use dbus::nonblock::stdintf::org_freedesktop_dbus::Properties;
78use dbus::nonblock::{Proxy, SyncConnection};
79use dbus_crossroads::{Crossroads, IfaceBuilder, MethodErr};
80use dbus_tokio::connection::IOResource;
81use tokio::sync::oneshot;
82use tokio_util::task::AbortOnDropHandle;
83use tracing::{debug, info, warn};
84
85use crate::pairing::{BluezError, PAIRING_RETRY_AFTER, PairOutcome, PairingBus, PairingCooldown};
86
87/// Object path of the session's agent, on the session's own connection.
88const AGENT_PATH: &str = "/dev/rye/aranet/agent";
89/// aranet has no display or keyboard to offer, so sensors pair with "Just Works".
90const AGENT_CAPABILITY: &str = "NoInputNoOutput";
91const BLUEZ_SERVICE: &str = "org.bluez";
92const BLUEZ_ROOT: &str = "/org/bluez";
93const AGENT_MANAGER_IFACE: &str = "org.bluez.AgentManager1";
94const DEVICE_IFACE: &str = "org.bluez.Device1";
95/// Limit for connecting to the system bus and for every BlueZ call but `Pair`:
96/// `bluetoothd` answers each of them at once. It answers `Pair` only when the
97/// pairing ends, so `run_pairing` limits `Pair` by the session's budget
98/// instead. A session therefore reports its outcome at most three of these
99/// after its budget: the bus connect, and `Paired` and `CancelPairing` after
100/// `Pair` ran out.
101const CALL_TIMEOUT: Duration = Duration::from_secs(1);
102/// `Pair`'s D-Bus reply timeout. `run_pairing` puts its own limits on `Pair`,
103/// so this only has to be longer than they can be. dbus adds it to
104/// `Instant::now()` without checking, so it can't be `Duration::MAX`.
105const PAIR_REPLY_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
106/// How long a sensor that refused to pair is left unpaired, for log messages.
107const RETRY_MINUTES: u64 = PAIRING_RETRY_AFTER.as_secs() / 60;
108
109/// Sensors that refused to pair, by BlueZ object path. Such a sensor costs at
110/// most one pairing attempt every 10 minutes.
111static COOLDOWN: LazyLock<Mutex<PairingCooldown>> =
112    LazyLock::new(|| Mutex::new(PairingCooldown::default()));
113
114/// Does nothing.
115///
116/// Up to 0.2.1 this registered a BlueZ agent for the whole process. aranet-core
117/// now pairs a sensor itself while connecting to it, through an agent that
118/// exists only for that pairing, so there is nothing to set up. This function
119/// will be removed in 0.4.0.
120#[deprecated(
121    since = "0.3.0",
122    note = "aranet-core now pairs devices itself while connecting; no process-wide agent is registered"
123)]
124pub fn ensure_agent() {}
125
126/// Pairs `peripheral` if BlueZ doesn't list it as paired, and logs how that
127/// went. `None` when the cooldown skipped the attempt.
128///
129/// It returns within about 3 s of `budget`. A failure is returned for the
130/// caller's information only: the caller connects either way.
131pub(crate) async fn pair_if_needed(
132    peripheral: &Peripheral,
133    budget: Duration,
134) -> Option<PairOutcome> {
135    let address = peripheral.address().to_string();
136    let Some(device) = device_path(&peripheral.id().to_string()) else {
137        let outcome = PairOutcome::Unavailable(BluezError {
138            name: "org.freedesktop.DBus.Error.InvalidArgs".into(),
139            message: format!("no BlueZ object path for {}", peripheral.id()),
140        });
141        log_outcome(&address, &outcome);
142        return Some(outcome);
143    };
144    let session = run_session(device.clone(), budget);
145    let outcome = crate::pairing::pair_with_cooldown(&COOLDOWN, &device, session).await;
146    match &outcome {
147        Some(outcome) => log_outcome(&address, outcome),
148        None => debug!(
149            "Not pairing with {address}: it refused to pair less than {RETRY_MINUTES} minutes ago"
150        ),
151    }
152    outcome
153}
154
155/// Runs a pairing session for `device` on aranet-core's runtime and returns
156/// its outcome as soon as the session reports it; the session may go on after
157/// that (see the module docs). If this future is dropped before the outcome is
158/// known, the session is aborted, which closes its connection.
159async fn run_session(device: dbus::Path<'static>, budget: Duration) -> PairOutcome {
160    // The runtime that polls the session's D-Bus connection drives its socket,
161    // so the session runs on aranet-ble rather than on the caller's runtime,
162    // which may have no I/O driver, and it can outlive the caller's wait.
163    let Some(runtime) = crate::link::cleanup_runtime() else {
164        return PairOutcome::Unavailable(failed("no tokio runtime to pair on"));
165    };
166    let (report, outcome) = oneshot::channel();
167    let session = AbortOnDropHandle::new(runtime.spawn(pair_session(device, budget, report)));
168    let outcome = outcome.await;
169    // The session has reported: let it finish on its own.
170    drop(session.detach());
171    outcome.unwrap_or_else(|_| {
172        PairOutcome::Unavailable(failed("the pairing session ended without an outcome"))
173    })
174}
175
176/// One pairing session for `device` on a private system-bus connection. It
177/// sends the outcome on `report` as soon as it is known, then unregisters the
178/// agent and, if BlueZ is holding `Pair`'s reply, waits for it.
179async fn pair_session(
180    device: dbus::Path<'static>,
181    budget: Duration,
182    report: oneshot::Sender<PairOutcome>,
183) {
184    let (resource, conn) = match connect_system_bus().await {
185        Ok(connection) => connection,
186        Err(e) => {
187            let _ = report.send(PairOutcome::Unavailable(e));
188            return;
189        }
190    };
191    let mut cr = agent_crossroads(device.clone());
192    conn.start_receive(
193        MatchRule::new_method_call(),
194        Box::new(move |msg, conn| {
195            if cr.handle_message(msg, conn).is_err() {
196                warn!("BlueZ agent: failed to handle a D-Bus message");
197            }
198            true
199        }),
200    );
201    let bus = DbusPairingBus {
202        conn,
203        device,
204        report: Mutex::new(Some(report)),
205    };
206    let mut resource = std::pin::pin!(resource);
207    tokio::select! {
208        // Poll the connection's I/O first, so tokio watches its socket before
209        // the first call goes out: dbus-tokio fails a call that libdbus can't
210        // write at once while the I/O future has never been polled.
211        biased;
212        err = &mut resource => bus.report(&PairOutcome::Unavailable(BluezError {
213            name: "org.freedesktop.DBus.Error.Disconnected".into(),
214            message: err.to_string(),
215        })),
216        _ = crate::pairing::run_pairing(&bus, budget) => {}
217    }
218    // `bus` and `resource` drop here and close the private connection. BlueZ
219    // then removes the agent even if `UnregisterAgent` failed, and cancels a
220    // pairing that is still running.
221}
222
223/// Connects to the system bus on the blocking pool (the connect blocks until
224/// the bus has answered `Hello`), giving up after `CALL_TIMEOUT`.
225async fn connect_system_bus()
226-> Result<(IOResource<SyncConnection>, Arc<SyncConnection>), BluezError> {
227    let connect = tokio::task::spawn_blocking(dbus_tokio::connection::new_system_sync);
228    match tokio::time::timeout(CALL_TIMEOUT, connect).await {
229        Ok(Ok(connection)) => connection.map_err(BluezError::from),
230        Ok(Err(e)) => Err(failed(e.to_string())),
231        Err(_) => Err(BluezError {
232            name: "org.freedesktop.DBus.Error.Timeout".into(),
233            message: "the system bus did not answer".into(),
234        }),
235    }
236}
237
238/// A failure that has no D-Bus error name of its own.
239fn failed(message: impl Into<String>) -> BluezError {
240    BluezError {
241        name: "org.freedesktop.DBus.Error.Failed".into(),
242        message: message.into(),
243    }
244}
245
246/// The BlueZ object path of a device, from the text of btleplug's Linux
247/// `PeripheralId`: the object path without `/org/bluez/`, such as
248/// `hci0/dev_AA_BB_CC_DD_EE_FF`.
249fn device_path(id_display: &str) -> Option<dbus::Path<'static>> {
250    if id_display.is_empty() {
251        return None;
252    }
253    dbus::Path::new(format!("{BLUEZ_ROOT}/{id_display}")).ok()
254}
255
256/// State of a session's agent: the one device it may approve.
257struct AgentData {
258    device: dbus::Path<'static>,
259}
260
261/// Reject agent requests for any device but the one being paired.
262fn check_session_device(data: &AgentData, device: &dbus::Path) -> Result<(), MethodErr> {
263    if *device == data.device {
264        Ok(())
265    } else {
266        warn!("BlueZ agent: rejecting request for {device} (not the device aranet is pairing)");
267        Err((
268            "org.bluez.Error.Rejected",
269            "Device is not being paired by aranet",
270        )
271            .into())
272    }
273}
274
275/// Answer BlueZ's `AuthorizeService` request: always reject.
276///
277/// BlueZ asks this when a remote device connects to one of the host's own
278/// profiles (HID input, audio, PAN, ...). aranet is only ever a GATT client of
279/// the sensor, so the Aranet flow never needs it. The device being paired is
280/// known by an address that Aranet devices broadcast in the clear, so approving
281/// here would let anyone spoofing that address reach those profiles, for
282/// example to inject keystrokes over HID.
283fn authorize_service(device: &dbus::Path, uuid: &str) -> Result<(), MethodErr> {
284    warn!(
285        "BlueZ agent: rejecting AuthorizeService {uuid} for {device} (aranet never accepts host profile connections)"
286    );
287    Err((
288        "org.bluez.Error.Rejected",
289        "aranet does not authorize host profile connections",
290    )
291        .into())
292}
293
294/// Answer BlueZ's `RequestPasskey` request: always reject.
295///
296/// BlueZ asks for a passkey when this side has to type in the one that the
297/// device shows, which takes a keyboard. The agent has none
298/// (`NoInputNoOutput`), so BlueZ pairs sensors with "Just Works", which never
299/// asks. If BlueZ asks anyway, aranet has no passkey to give for any device,
300/// and rejects the request rather than make one up.
301fn request_passkey(device: &dbus::Path) -> Result<(u32,), MethodErr> {
302    warn!("BlueZ agent: rejecting RequestPasskey for {device} (aranet can't enter a passkey)");
303    Err(("org.bluez.Error.Rejected", "aranet can't enter a passkey").into())
304}
305
306/// The `org.bluez.Agent1` object of one pairing session, which approves
307/// requests for `device` only.
308fn agent_crossroads(device: dbus::Path<'static>) -> Crossroads {
309    let mut cr = Crossroads::new();
310    let token = cr.register("org.bluez.Agent1", |b: &mut IfaceBuilder<AgentData>| {
311        b.method("Release", (), (), |_, _, ()| {
312            debug!("BlueZ agent: Release");
313            Ok(())
314        });
315
316        b.method(
317            "RequestPasskey",
318            ("device",),
319            ("passkey",),
320            |_, _, (device,): (dbus::Path,)| request_passkey(&device),
321        );
322
323        b.method(
324            "RequestConfirmation",
325            ("device", "passkey"),
326            (),
327            |_, data, (device, passkey): (dbus::Path, u32)| {
328                debug!("BlueZ agent: RequestConfirmation for {device}, passkey {passkey}");
329                check_session_device(data, &device)
330            },
331        );
332
333        b.method(
334            "RequestAuthorization",
335            ("device",),
336            (),
337            |_, data, (device,): (dbus::Path,)| {
338                debug!("BlueZ agent: RequestAuthorization for {device}");
339                check_session_device(data, &device)
340            },
341        );
342
343        b.method(
344            "AuthorizeService",
345            ("device", "uuid"),
346            (),
347            |_, _, (device, uuid): (dbus::Path, String)| authorize_service(&device, &uuid),
348        );
349
350        b.method("Cancel", (), (), |_, _, ()| {
351            debug!("BlueZ agent: Cancel");
352            Ok(())
353        });
354    });
355    cr.insert(AGENT_PATH, &[token], AgentData { device });
356    cr
357}
358
359/// BlueZ's D-Bus API for one pairing session, on the session's connection.
360struct DbusPairingBus {
361    conn: Arc<SyncConnection>,
362    /// The sensor's BlueZ object path.
363    device: dbus::Path<'static>,
364    /// Where `report` sends the outcome; `None` once it has.
365    report: Mutex<Option<oneshot::Sender<PairOutcome>>>,
366}
367
368impl DbusPairingBus {
369    fn proxy(
370        &self,
371        path: dbus::Path<'static>,
372        timeout: Duration,
373    ) -> Proxy<'static, Arc<SyncConnection>> {
374        Proxy::new(BLUEZ_SERVICE, path, timeout, Arc::clone(&self.conn))
375    }
376}
377
378impl PairingBus for DbusPairingBus {
379    async fn is_paired(&self) -> Result<bool, BluezError> {
380        // `Paired`, not `Bonded`: BlueZ 5.64 (Ubuntu 22.04) has no `Bonded` property.
381        self.proxy(self.device.clone(), CALL_TIMEOUT)
382            .get::<bool>(DEVICE_IFACE, "Paired")
383            .await
384            .map_err(BluezError::from)
385    }
386
387    async fn register_agent(&self) -> Result<(), BluezError> {
388        self.proxy(dbus::Path::from(BLUEZ_ROOT), CALL_TIMEOUT)
389            .method_call(
390                AGENT_MANAGER_IFACE,
391                "RegisterAgent",
392                (dbus::Path::from(AGENT_PATH), AGENT_CAPABILITY),
393            )
394            .await
395            .map_err(BluezError::from)
396    }
397
398    async fn pair(&self) -> Result<(), BluezError> {
399        self.proxy(self.device.clone(), PAIR_REPLY_TIMEOUT)
400            .method_call(DEVICE_IFACE, "Pair", ())
401            .await
402            .map_err(BluezError::from)
403    }
404
405    async fn cancel_pairing(&self) -> Result<(), BluezError> {
406        self.proxy(self.device.clone(), CALL_TIMEOUT)
407            .method_call(DEVICE_IFACE, "CancelPairing", ())
408            .await
409            .map_err(BluezError::from)
410    }
411
412    async fn unregister_agent(&self) -> Result<(), BluezError> {
413        self.proxy(dbus::Path::from(BLUEZ_ROOT), CALL_TIMEOUT)
414            .method_call(
415                AGENT_MANAGER_IFACE,
416                "UnregisterAgent",
417                (dbus::Path::from(AGENT_PATH),),
418            )
419            .await
420            .map_err(BluezError::from)
421    }
422
423    fn report(&self, outcome: &PairOutcome) {
424        let report = self
425            .report
426            .lock()
427            .unwrap_or_else(PoisonError::into_inner)
428            .take();
429        if let Some(report) = report {
430            let _ = report.send(outcome.clone());
431        }
432    }
433}
434
435impl From<dbus::Error> for BluezError {
436    fn from(e: dbus::Error) -> Self {
437        BluezError {
438            name: e
439                .name()
440                .unwrap_or("org.freedesktop.DBus.Error.Failed")
441                .to_owned(),
442            message: e.message().unwrap_or("").to_owned(),
443        }
444    }
445}
446
447/// Log how a pairing went, with the commands that fix a failed one.
448fn log_outcome(address: &str, outcome: &PairOutcome) {
449    let failure = match outcome {
450        PairOutcome::AlreadyPaired => {
451            debug!("{address} is already paired");
452            return;
453        }
454        PairOutcome::Paired => {
455            info!("Paired with {address}");
456            return;
457        }
458        PairOutcome::Unavailable(e) => {
459            warn!("Could not pair with {address}: {e}");
460            return;
461        }
462        PairOutcome::Failed(e) => format!("failed ({e})"),
463        PairOutcome::TimedOut => "timed out".to_owned(),
464    };
465    let retry = if outcome.refused_by_sensor() {
466        format!("aranet will not try to pair it again for {RETRY_MINUTES} minutes")
467    } else {
468        "aranet will try again at the next connection".to_owned()
469    };
470    warn!(
471        "Pairing with {address} {failure}; {retry}. Until it is paired, BlueZ asks for pairing \
472         at each connection, which can make reads time out on a host without a Bluetooth agent. \
473         aranet can't enter a PIN: if the sensor asks for one, pair it by hand once. Stop aranet \
474         and the aranet service first (a connect during the pairing cancels it), run \
475         `bluetoothctl remove {address}` if it was paired with this computer before, then run \
476         `bluetoothctl` and, at its prompt, `scan on` until the sensor is listed, `scan off` \
477         and `pair {address}`, entering the PIN when asked"
478    );
479}
480
481#[cfg(test)]
482mod tests {
483    use std::cell::RefCell;
484    use std::ffi::CString;
485    use std::sync::Weak;
486
487    use dbus::Message;
488    use dbus::arg::Variant;
489    use dbus::channel::Sender;
490    use dbus::nonblock::stdintf::org_freedesktop_dbus::RequestNameReply;
491    use dbus::strings::ErrorName;
492    use tokio::sync::Notify;
493
494    use super::*;
495    use crate::pairing::ALREADY_EXISTS;
496    use crate::test_support::within;
497
498    /// The sensor being paired, and another device nearby.
499    const A: &str = "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_01";
500    const B: &str = "/org/bluez/hci0/dev_11_22_33_44_55_66";
501    /// Human Interface Device: approving it would let a spoofed sensor type keystrokes.
502    const HID_SERVICE: &str = "00001124-0000-1000-8000-00805f9b34fb";
503    const PASSKEY: u32 = 123_456;
504
505    fn path(p: &str) -> dbus::Path<'static> {
506        dbus::Path::new(p.to_owned()).unwrap()
507    }
508
509    fn agent_call(method: &str) -> Message {
510        Message::new_method_call("org.bluez", AGENT_PATH, "org.bluez.Agent1", method).unwrap()
511    }
512
513    /// Hand `call` to the agent of a session that pairs `session_device`, as
514    /// BlueZ would, and return the agent's only reply.
515    fn dispatch(session_device: &str, mut call: Message) -> Message {
516        let mut cr = agent_crossroads(path(session_device));
517        call.set_serial(1);
518        let sent = RefCell::new(Vec::new());
519        cr.handle_message(call, &sent).unwrap();
520        let mut replies = sent.into_inner();
521        assert_eq!(replies.len(), 1, "the agent should send exactly one reply");
522        replies.remove(0)
523    }
524
525    fn assert_approved(mut reply: Message) {
526        reply
527            .as_result()
528            .expect("the agent should approve this request");
529    }
530
531    fn assert_rejected(mut reply: Message) {
532        let err = reply
533            .as_result()
534            .expect_err("the agent should reject this request");
535        assert_eq!(err.name(), Some("org.bluez.Error.Rejected"));
536    }
537
538    /// Guards source compatibility: the deprecated `ensure_agent` stays
539    /// callable, so existing callers keep compiling, and spawns nothing, so a
540    /// call outside a tokio runtime doesn't panic.
541    #[test]
542    #[allow(deprecated)]
543    fn deprecated_ensure_agent_is_a_no_op() {
544        // No tokio runtime here: up to aranet-core 0.2.1, `ensure_agent`
545        // spawned a task, which panics outside a runtime.
546        ensure_agent();
547    }
548
549    #[test]
550    fn agent_approves_only_the_device_being_paired() {
551        assert_approved(dispatch(
552            A,
553            agent_call("RequestAuthorization").append1(path(A)),
554        ));
555        assert_approved(dispatch(
556            A,
557            agent_call("RequestConfirmation").append2(path(A), PASSKEY),
558        ));
559
560        assert_rejected(dispatch(
561            A,
562            agent_call("RequestAuthorization").append1(path(B)),
563        ));
564        assert_rejected(dispatch(
565            A,
566            agent_call("RequestConfirmation").append2(path(B), PASSKEY),
567        ));
568    }
569
570    #[test]
571    fn agent_sessions_do_not_share_approvals() {
572        // In aranet-core 0.2.1, one agent for the whole process approved
573        // every device aranet had connected to, so it kept approving A after
574        // A's connect was over. Each session's agent approves only its own.
575        assert_approved(dispatch(
576            A,
577            agent_call("RequestAuthorization").append1(path(A)),
578        ));
579        assert_rejected(dispatch(
580            B,
581            agent_call("RequestAuthorization").append1(path(A)),
582        ));
583    }
584
585    #[test]
586    fn agent_rejects_authorize_service_even_for_the_device_being_paired() {
587        assert_rejected(dispatch(
588            A,
589            agent_call("AuthorizeService").append2(path(A), HID_SERVICE),
590        ));
591    }
592
593    #[test]
594    fn agent_rejects_request_passkey_even_for_the_device_being_paired() {
595        // The agent has no keyboard, so it has no passkey to enter for any
596        // device, and must not make one up.
597        for device in [A, B] {
598            assert_rejected(dispatch(
599                A,
600                agent_call("RequestPasskey").append1(path(device)),
601            ));
602        }
603    }
604
605    #[test]
606    fn agent_acknowledges_release_and_cancel() {
607        assert_approved(dispatch(A, agent_call("Release")));
608        assert_approved(dispatch(A, agent_call("Cancel")));
609    }
610
611    #[test]
612    fn device_path_from_peripheral_id() {
613        assert_eq!(
614            device_path("hci0/dev_AA_BB_CC_DD_EE_FF"),
615            Some(path("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"))
616        );
617        assert_eq!(device_path(""), None);
618        assert_eq!(device_path("hci0/dev AA"), None);
619    }
620
621    #[test]
622    fn bluez_error_keeps_dbus_name() {
623        let error = BluezError::from(dbus::Error::new_custom(ALREADY_EXISTS, "Already Paired"));
624        assert_eq!(error.name, ALREADY_EXISTS);
625        assert_eq!(error.message, "Already Paired");
626    }
627
628    // The pairing session against a fake BlueZ on a throwaway bus.
629
630    /// `interface.member` of the BlueZ calls a session makes.
631    const GET: &str = "org.freedesktop.DBus.Properties.Get";
632    const REGISTER: &str = "org.bluez.AgentManager1.RegisterAgent";
633    const PAIR: &str = "org.bluez.Device1.Pair";
634    const CANCEL: &str = "org.bluez.Device1.CancelPairing";
635    const UNREGISTER: &str = "org.bluez.AgentManager1.UnregisterAgent";
636
637    /// How the fake BlueZ answers `Device1.Pair`.
638    #[derive(Clone, Copy)]
639    enum PairAnswer {
640        /// Ask the caller's agent to confirm a passkey for this device, then
641        /// answer `Pair` with success, or with `AuthenticationRejected` if the
642        /// agent refused.
643        AskAbout(&'static str),
644        /// Never answer.
645        Hang,
646        /// Bond at once (`Paired` turns true), but hold the reply until the
647        /// test calls `answer_held_pair`, as BlueZ does until its service
648        /// discovery has finished.
649        BondThenHold,
650    }
651
652    /// What the fake BlueZ does, and what it has received, in one scenario.
653    struct Scenario {
654        /// The sensor's `Device1.Paired`.
655        paired: bool,
656        pair: PairAnswer,
657        /// `(sender, interface, member)` of each method call, in order.
658        received: Vec<(String, String, String)>,
659        /// Notified when a `Pair` call is left unanswered.
660        pair_held: Arc<Notify>,
661        /// The `Pair` call that `BondThenHold` hasn't answered yet.
662        held_pair: Option<Message>,
663    }
664
665    /// A fake `org.bluez` on its own connection to the test bus, with one
666    /// sensor, `A`.
667    struct FakeBluez {
668        conn: Arc<SyncConnection>,
669        scenario: Arc<Mutex<Scenario>>,
670    }
671
672    impl FakeBluez {
673        async fn start() -> Self {
674            let (resource, conn) =
675                dbus_tokio::connection::new_system_sync().expect("connect to the test bus");
676            tokio::spawn(async move {
677                let err = resource.await;
678                eprintln!("the fake BlueZ lost its bus connection: {err}");
679            });
680            let reply = conn
681                .request_name("org.bluez", false, false, true)
682                .await
683                .expect("RequestName");
684            assert_eq!(
685                reply,
686                RequestNameReply::PrimaryOwner,
687                "another connection owns org.bluez on the test bus"
688            );
689            let scenario = Arc::new(Mutex::new(Scenario {
690                paired: false,
691                pair: PairAnswer::Hang,
692                received: Vec::new(),
693                pair_held: Arc::new(Notify::new()),
694                held_pair: None,
695            }));
696            let state = Arc::clone(&scenario);
697            let me = Arc::downgrade(&conn);
698            conn.start_receive(
699                MatchRule::new_method_call(),
700                Box::new(move |msg, conn| {
701                    fake_bluez_answer(msg, conn, &state, &me);
702                    true
703                }),
704            );
705            Self { conn, scenario }
706        }
707
708        /// Start a scenario: forget the calls received so far. Returns the
709        /// notifier of this scenario's unanswered `Pair`.
710        fn script(&self, paired: bool, pair: PairAnswer) -> Arc<Notify> {
711            let mut scenario = self.scenario.lock().unwrap();
712            scenario.paired = paired;
713            scenario.pair = pair;
714            scenario.received.clear();
715            scenario.pair_held = Arc::new(Notify::new());
716            scenario.held_pair = None;
717            Arc::clone(&scenario.pair_held)
718        }
719
720        /// `interface.member` of each call in this scenario, in order, after
721        /// checking that one connection made them all.
722        fn calls(&self) -> Vec<String> {
723            let scenario = self.scenario.lock().unwrap();
724            let received = &scenario.received;
725            assert!(
726                received
727                    .iter()
728                    .all(|(sender, _, _)| *sender == received[0].0),
729                "the calls came from more than one connection: {received:?}"
730            );
731            received
732                .iter()
733                .map(|(_, interface, member)| format!("{interface}.{member}"))
734                .collect()
735        }
736
737        /// Answer the `Pair` call that `BondThenHold` held back.
738        fn answer_held_pair(&self) {
739            let pair = self.scenario.lock().unwrap().held_pair.take();
740            let pair = pair.expect("no Pair call is being held");
741            let _ = self.conn.send(pair.method_return());
742        }
743
744        /// Whether the connection that made this scenario's calls is still on
745        /// the bus.
746        async fn session_is_open(&self) -> bool {
747            let name = self.scenario.lock().unwrap().received[0].0.clone();
748            let dbus = Proxy::new(
749                "org.freedesktop.DBus",
750                "/org/freedesktop/DBus",
751                Duration::from_secs(5),
752                Arc::clone(&self.conn),
753            );
754            let (owned,): (bool,) = dbus
755                .method_call("org.freedesktop.DBus", "NameHasOwner", (name.as_str(),))
756                .await
757                .expect("NameHasOwner");
758            owned
759        }
760
761        /// Fails unless the connection that made this scenario's calls leaves
762        /// the bus within 2 s. After it has, every call it made has been
763        /// received.
764        async fn assert_session_closed(&self) {
765            let closed = tokio::time::timeout(Duration::from_secs(2), async {
766                while self.session_is_open().await {
767                    tokio::time::sleep(Duration::from_millis(10)).await;
768                }
769            })
770            .await;
771            assert!(
772                closed.is_ok(),
773                "the session's connection was still open 2 s after the session ended"
774            );
775        }
776    }
777
778    /// Answer one method call as BlueZ would on a host with the one sensor `A`.
779    /// A call with other arguments, or to any other method (such as
780    /// `RequestDefaultAgent`), gets an error.
781    fn fake_bluez_answer(
782        msg: Message,
783        conn: &SyncConnection,
784        scenario: &Mutex<Scenario>,
785        me: &Weak<SyncConnection>,
786    ) {
787        let sender = msg.sender().map(|s| s.to_string()).unwrap_or_default();
788        let interface = msg.interface().map(|i| i.to_string()).unwrap_or_default();
789        let member = msg.member().map(|m| m.to_string()).unwrap_or_default();
790        let on_sensor = msg.path().is_some_and(|p| &*p == A);
791        let on_manager = msg.path().is_some_and(|p| &*p == "/org/bluez");
792        let (paired, pair, pair_held) = {
793            let mut scenario = scenario.lock().unwrap();
794            scenario
795                .received
796                .push((sender.clone(), interface.clone(), member.clone()));
797            (
798                scenario.paired,
799                scenario.pair,
800                Arc::clone(&scenario.pair_held),
801            )
802        };
803        let reply = match (interface.as_str(), member.as_str()) {
804            ("org.freedesktop.DBus.Properties", "Get")
805                if on_sensor
806                    && msg
807                        .read2::<&str, &str>()
808                        .is_ok_and(|args| args == ("org.bluez.Device1", "Paired")) =>
809            {
810                msg.method_return().append1(Variant(paired))
811            }
812            ("org.bluez.AgentManager1", "RegisterAgent")
813                if on_manager
814                    && msg
815                        .read2::<dbus::Path, &str>()
816                        .is_ok_and(|(agent, capability)| {
817                            &*agent == "/dev/rye/aranet/agent" && capability == "NoInputNoOutput"
818                        }) =>
819            {
820                msg.method_return()
821            }
822            ("org.bluez.AgentManager1", "UnregisterAgent")
823                if on_manager
824                    && msg
825                        .read1::<dbus::Path>()
826                        .is_ok_and(|agent| &*agent == "/dev/rye/aranet/agent") =>
827            {
828                msg.method_return()
829            }
830            ("org.bluez.Device1", "CancelPairing") if on_sensor => msg.method_return(),
831            ("org.bluez.Device1", "Pair") if on_sensor => {
832                match pair {
833                    PairAnswer::Hang => pair_held.notify_one(),
834                    PairAnswer::BondThenHold => {
835                        let mut scenario = scenario.lock().unwrap();
836                        scenario.paired = true;
837                        scenario.held_pair = Some(msg);
838                        pair_held.notify_one();
839                    }
840                    PairAnswer::AskAbout(device) => {
841                        if let Some(conn) = me.upgrade() {
842                            tokio::spawn(answer_pair(conn, msg, sender, device));
843                        }
844                    }
845                }
846                return;
847            }
848            _ => msg.error(
849                &ErrorName::from("org.bluez.Error.NotSupported"),
850                &CString::new("not expected by this test").unwrap(),
851            ),
852        };
853        let _ = conn.send(reply);
854    }
855
856    /// Ask the agent that `owner` registered to confirm a passkey for
857    /// `device`, then answer the `Pair` call as BlueZ would.
858    async fn answer_pair(
859        conn: Arc<SyncConnection>,
860        pair: Message,
861        owner: String,
862        device: &'static str,
863    ) {
864        let agent = Proxy::new(
865            owner,
866            "/dev/rye/aranet/agent",
867            Duration::from_secs(5),
868            Arc::clone(&conn),
869        );
870        let confirmed: Result<(), dbus::Error> = agent
871            .method_call(
872                "org.bluez.Agent1",
873                "RequestConfirmation",
874                (path(device), PASSKEY),
875            )
876            .await;
877        let reply = match confirmed {
878            Ok(()) => pair.method_return(),
879            Err(_) => pair.error(
880                &ErrorName::from("org.bluez.Error.AuthenticationRejected"),
881                &CString::new("Authentication Rejected").unwrap(),
882            ),
883        };
884        let _ = conn.send(reply);
885    }
886
887    /// Runs pairing sessions against `FakeBluez`, scenarios (a) to (f) below.
888    /// The test takes the name `org.bluez` on the bus that
889    /// `DBUS_SYSTEM_BUS_ADDRESS` names, so it needs a throwaway `dbus-daemon`
890    /// whose policy lets any connection own any name, never the host's system
891    /// bus. It runs on the real clock and takes about 6 s, most of it scenario
892    /// (d)'s 1 s budget and scenario (f)'s 3 s budget and 1.5 s wait.
893    ///
894    /// To run it in Docker from the repository root, set `IMAGE` to a Debian
895    /// image with Rust 1.90 or later, `pkg-config` and `libdbus-1-dev` (such as
896    /// `rust:1.90` with those two packages added). The command installs `dbus`,
897    /// for `dbus-daemon`, if the image doesn't have it:
898    ///
899    /// ```text
900    /// docker run --rm -v "$PWD":/work -w /work -e CARGO_TARGET_DIR=/work/target/linux "$IMAGE" bash -c 'set -e
901    /// command -v dbus-daemon > /dev/null || { apt-get update -qq && apt-get install -y -qq dbus > /dev/null; }
902    /// cat > /tmp/aranet-test-bus.conf <<EOF
903    /// <!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
904    /// <busconfig><type>system</type><listen>unix:path=/tmp/aranet-test-bus</listen><auth>EXTERNAL</auth>
905    /// <policy context="default"><allow user="*"/><allow own="*"/><allow send_destination="*"/><allow receive_sender="*"/></policy></busconfig>
906    /// EOF
907    /// dbus-daemon --config-file=/tmp/aranet-test-bus.conf --fork
908    /// DBUS_SYSTEM_BUS_ADDRESS=unix:path=/tmp/aranet-test-bus cargo test --locked -p aranet-core --lib pairing_session_over_a_private_bus -- --ignored --nocapture'
909    /// ```
910    #[tokio::test]
911    #[ignore = "needs a throwaway dbus-daemon; see this test's doc for the Docker command"]
912    async fn pairing_session_over_a_private_bus() {
913        assert!(
914            std::env::var_os("DBUS_SYSTEM_BUS_ADDRESS").is_some(),
915            "set DBUS_SYSTEM_BUS_ADDRESS to a throwaway dbus-daemon: this test takes the name org.bluez on that bus"
916        );
917        within(Duration::from_secs(120), async {
918            let bluez = FakeBluez::start().await;
919            let budget = Duration::from_secs(10);
920
921            // (a) An unpaired sensor pairs through the session's own agent, and
922            // nothing asks to be the default agent.
923            bluez.script(false, PairAnswer::AskAbout(A));
924            assert_eq!(run_session(path(A), budget).await, PairOutcome::Paired);
925            bluez.assert_session_closed().await;
926            assert_eq!(bluez.calls(), [GET, REGISTER, PAIR, UNREGISTER]);
927
928            // (b) The agent refuses a request about another device.
929            bluez.script(false, PairAnswer::AskAbout(B));
930            let outcome = run_session(path(A), budget).await;
931            assert!(
932                matches!(&outcome, PairOutcome::Failed(e) if e.name == "org.bluez.Error.AuthenticationRejected"),
933                "{outcome:?}"
934            );
935            bluez.assert_session_closed().await;
936            assert_eq!(bluez.calls(), [GET, REGISTER, PAIR, UNREGISTER]);
937
938            // (c) A paired sensor registers no agent and isn't paired again.
939            bluez.script(true, PairAnswer::Hang);
940            assert_eq!(
941                run_session(path(A), budget).await,
942                PairOutcome::AlreadyPaired
943            );
944            bluez.assert_session_closed().await;
945            assert_eq!(bluez.calls(), [GET]);
946
947            // (d) A Pair that never answers is cancelled when its budget runs
948            // out, because the sensor still isn't paired.
949            bluez.script(false, PairAnswer::Hang);
950            assert_eq!(
951                run_session(path(A), Duration::from_secs(1)).await,
952                PairOutcome::TimedOut
953            );
954            bluez.assert_session_closed().await;
955            assert_eq!(bluez.calls(), [GET, REGISTER, PAIR, GET, CANCEL, UNREGISTER]);
956
957            // (e) A caller that drops the session during Pair closes the
958            // connection too, so BlueZ drops the agent and the pairing.
959            let pair_held = bluez.script(false, PairAnswer::Hang);
960            tokio::select! {
961                outcome = run_session(path(A), budget) => {
962                    panic!("the session should still be pairing, got {outcome:?}")
963                }
964                () = pair_held.notified() => {}
965            }
966            bluez.assert_session_closed().await;
967            assert_eq!(bluez.calls(), [GET, REGISTER, PAIR]);
968
969            // (f) BlueZ bonded but holds Pair's reply for its service
970            // discovery: the budget runs out, the session reports Paired
971            // without cancelling, and keeps its connection open until BlueZ
972            // answers. No event marks "still open", so this waits 1.5 s: a
973            // session that closed after reporting, or whose Pair call timed
974            // out 1 s after its budget, is gone by then.
975            bluez.script(false, PairAnswer::BondThenHold);
976            assert_eq!(
977                run_session(path(A), Duration::from_secs(3)).await,
978                PairOutcome::Paired
979            );
980            tokio::time::sleep(Duration::from_millis(1500)).await;
981            assert!(
982                bluez.session_is_open().await,
983                "the session closed its connection before BlueZ answered Pair"
984            );
985            bluez.answer_held_pair();
986            bluez.assert_session_closed().await;
987            assert_eq!(bluez.calls(), [GET, REGISTER, PAIR, GET, UNREGISTER]);
988        })
989        .await;
990    }
991}