fix(bt): framing, roles, NodeId binding, contact events
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
# tines-bt
|
||||||
|
|
||||||
|
The BLE transport for tines: a [`Transport`] implementation whose address is
|
||||||
|
the peer's advertised [`NodeId`], so identity survives BLE address rotation.
|
||||||
|
|
||||||
|
Real radio I/O sits behind the small [`Radio`] trait (implemented over
|
||||||
|
`bluer`/BlueZ by [`BluerRadio`]); everything else — the `from`/`Addr` check, the
|
||||||
|
central/peripheral role decision, and the address→`NodeId` binding — is pure and
|
||||||
|
unit-testable without hardware.
|
||||||
|
|
||||||
|
## Framing
|
||||||
|
|
||||||
|
Connections are `bluer` [`l2cap::SeqPacket`] channels (LE L2CAP
|
||||||
|
Connection-Capable Channel): each received SDU is exactly one frame. The MTU is
|
||||||
|
clamped to [`MAX_FRAME`], so a single SDU is a single wire envelope — no
|
||||||
|
length-prefix framing is needed.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
Every node advertises **and** scans. To keep two nodes from both dialing, the
|
||||||
|
numerically-smaller [`NodeId`] acts as the BLE central (initiator) and the
|
||||||
|
larger accepts on the [`SeqPacketListener`] bound to PSM `0x008D`. The listener
|
||||||
|
runs for the whole session.
|
||||||
|
|
||||||
|
## Binding
|
||||||
|
|
||||||
|
The BLE address is only a routing key (it rotates), so a connection is bound to
|
||||||
|
the peer's advertised [`NodeId`] instead:
|
||||||
|
|
||||||
|
- the scanner decodes the peer's [`NodeId`] from a tines advertisement — the
|
||||||
|
`Service Data — 16-bit UUID` record must carry the tines service UUID
|
||||||
|
(`0xFE7E`), which is checked by [`adv::decode_ad_node_id`];
|
||||||
|
- the decoded `bt address → NodeId` pair is recorded in the scan map;
|
||||||
|
- an **inbound** connection is bound by looking its peer address up in that map
|
||||||
|
— an inbound from an unknown address is rejected immediately;
|
||||||
|
- an **outbound** connection dials using the discovered address *type*, not a
|
||||||
|
hard-coded one.
|
||||||
|
|
||||||
|
A frame's declared sender is then checked against the bound [`NodeId`]: the
|
||||||
|
transport peeks the `from` field with [`wire::frame_sender`] and drops any frame
|
||||||
|
whose declared sender differs from the bound peer (an address-spoofing
|
||||||
|
violation).
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
- connection up → `Event::Contact(addr)`;
|
||||||
|
- connection end (EOF, error, or adapter loss) → `Event::Lost(addr)`;
|
||||||
|
- scanning and listening continue after a loss.
|
||||||
|
|
||||||
|
## Multi-peer
|
||||||
|
|
||||||
|
Up to [`MAX_PEERS`] connections may be live at once, one per bound [`NodeId`];
|
||||||
|
a second connection to the same [`NodeId`] replaces the first.
|
||||||
|
|
||||||
|
## Manual verification
|
||||||
|
|
||||||
|
The SeqPacket framing can't be exercised in CI (it needs two real radios), so
|
||||||
|
verify it by hand with two devices running the `tines` service:
|
||||||
|
|
||||||
|
1. start `tines` on both devices with distinct `NodeId`s;
|
||||||
|
2. confirm the smaller-`NodeId` device dials and the larger accepts (check the
|
||||||
|
`tines-bt: connected to … (central)` / `accepted connection from … (bound)`
|
||||||
|
trace lines);
|
||||||
|
3. send a burst of observations on one device and confirm the other receives
|
||||||
|
each as a separate, intact frame (no merged or truncated frames).
|
||||||
|
|
||||||
|
[`Transport`]: https://docs.rs/tines-core/latest/tines_core/transport/trait.Transport.html
|
||||||
|
[`NodeId`]: https://docs.rs/tines-core/latest/tines_core/observation/struct.NodeId.html
|
||||||
|
[`Radio`]: crate::Radio
|
||||||
|
[`BluerRadio`]: crate::BluerRadio
|
||||||
|
[`adv::decode_ad_node_id`]: crate::adv::decode_ad_node_id
|
||||||
|
[`wire::frame_sender`]: https://docs.rs/tines-core/latest/tines_core/wire/fn.frame_sender.html
|
||||||
|
[`MAX_FRAME`]: https://docs.rs/tines-core/latest/tines_core/consts/constant.MAX_FRAME.html
|
||||||
|
[`MAX_PEERS`]: https://docs.rs/tines-core/latest/tines_core/consts/constant.MAX_PEERS.html
|
||||||
|
[`l2cap::SeqPacket`]: https://docs.rs/bluer/latest/bluer/l2cap/struct.SeqPacket.html
|
||||||
|
[`SeqPacketListener`]: https://docs.rs/bluer/latest/bluer/l2cap/struct.SeqPacketListener.html
|
||||||
@@ -10,8 +10,12 @@
|
|||||||
//! of the service-data record is exactly what [`decode_node_id`] does — the
|
//! of the service-data record is exactly what [`decode_node_id`] does — the
|
||||||
//! invariant the round-trip test pins.
|
//! invariant the round-trip test pins.
|
||||||
|
|
||||||
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
|
||||||
use tines_core::observation::NodeId;
|
use tines_core::observation::NodeId;
|
||||||
|
|
||||||
|
use bluer::{Uuid, UuidExt};
|
||||||
|
|
||||||
/// The LE flags byte carried in the flags AD record: LE General Available and
|
/// The LE flags byte carried in the flags AD record: LE General Available and
|
||||||
/// BR/EDR not supported (`0x06`).
|
/// BR/EDR not supported (`0x06`).
|
||||||
pub const ADV_FLAGS: u8 = 0x06;
|
pub const ADV_FLAGS: u8 = 0x06;
|
||||||
@@ -109,6 +113,34 @@ pub fn decode_node_id(payload: &[u8]) -> Option<NodeId> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode a [`NodeId`] advertised by a tines advertiser.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the advertisement does not carry the tines service UUID,
|
||||||
|
/// if no [`NodeId`] can be parsed, or if the advertiser is this local
|
||||||
|
/// [`NodeId`] (a node advertises *and* scans, so it never dials itself).
|
||||||
|
///
|
||||||
|
/// Unlike [`decode_node_id`], the 2-byte service UUID is *verified* here, so a
|
||||||
|
/// frame from a non-tines broadcaster is rejected.
|
||||||
|
///
|
||||||
|
/// A non-empty `allow` narrows decoding to a whitelist of [`NodeId`]s; an empty
|
||||||
|
/// `allow` accepts any tines advertiser (this is the default once the
|
||||||
|
/// transport binds by NodeId).
|
||||||
|
pub fn decode_ad_node_id(
|
||||||
|
service_data: &HashMap<Uuid, Vec<u8>>,
|
||||||
|
me: NodeId,
|
||||||
|
allow: &BTreeSet<NodeId>,
|
||||||
|
) -> Option<NodeId> {
|
||||||
|
let advertised = service_data.get(&Uuid::from_u16(SERVICE_UUID))?;
|
||||||
|
let node: NodeId = advertised.as_slice().try_into().ok()?;
|
||||||
|
if node == me {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !allow.is_empty() && !allow.contains(&node) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(node)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+3
-1
@@ -18,4 +18,6 @@ mod transport;
|
|||||||
pub use adv::{
|
pub use adv::{
|
||||||
advertise, decode_node_id, ADVERTISING_BUFFER_LEN, ADV_FLAGS, NODE_ID_LEN, SERVICE_UUID,
|
advertise, decode_node_id, ADVERTISING_BUFFER_LEN, ADV_FLAGS, NODE_ID_LEN, SERVICE_UUID,
|
||||||
};
|
};
|
||||||
pub use transport::{is_central, BluerRadio, BtTransport, Radio, RadioError, RadioEvent};
|
pub use transport::{
|
||||||
|
is_central, resolve_bound, BluerRadio, BtTransport, Radio, RadioError, RadioEvent,
|
||||||
|
};
|
||||||
|
|||||||
+440
-135
@@ -3,14 +3,60 @@
|
|||||||
//!
|
//!
|
||||||
//! Real radio I/O sits behind the small [`Radio`] trait, implemented over
|
//! Real radio I/O sits behind the small [`Radio`] trait, implemented over
|
||||||
//! `bluer`/BlueZ in [`BluerRadio`]. Everything here that does not touch the
|
//! `bluer`/BlueZ in [`BluerRadio`]. Everything here that does not touch the
|
||||||
//! radio — the from/`Addr` mismatch-drop rule and the central-peripheral role
|
//! radio — the from/`Addr` mismatch-drop rule, the central-peripheral role
|
||||||
//! decision — is pure and unit-testable without hardware.
|
//! decision, and the inbound-address → `NodeId` binding — is pure and
|
||||||
|
//! unit-testable without hardware.
|
||||||
|
//!
|
||||||
|
//! ## Framing
|
||||||
|
//!
|
||||||
|
//! The connection is a `bluer` [`l2cap::SeqPacket`] (a LE L2CAP Connection
|
||||||
|
//! Capable Channel): each received SDU is exactly one frame (one
|
||||||
|
//! [`RadioEvent::Data`]). The MTU is clamped to [`MAX_FRAME`] so one SDU is one
|
||||||
|
//! envelope.
|
||||||
|
//!
|
||||||
|
//! ## Roles
|
||||||
|
//!
|
||||||
|
//! Every node advertises *and* scans. To avoid two nodes both dialing, the
|
||||||
|
//! numerically-smaller [`NodeId`] dials (the central) and the larger accepts on
|
||||||
|
//! the [`SeqPacketListener`] bound to `TINES_L2CAP_PSM`.
|
||||||
|
//!
|
||||||
|
//! ## Binding
|
||||||
|
//!
|
||||||
|
//! The BLE address is only a routing key (it rotates), so the transport binds a
|
||||||
|
//! connection to the peer's advertised [`NodeId`]: the scanner decodes the
|
||||||
|
//! [`NodeId`] from a tines advertisement (UUID-checked via [`adv::decode_ad_node_id`])
|
||||||
|
//! and records `bt address → NodeId`; inbound connections are bound via the
|
||||||
|
//! scanned address (an unknown address is rejected).
|
||||||
|
//!
|
||||||
|
//! ## Multi-peer
|
||||||
|
//!
|
||||||
|
//! Up to [`MAX_PEERS`] connections may be live at once, one per bound
|
||||||
|
//! [`NodeId`]. A second connection to the same [`NodeId`] replaces the first.
|
||||||
|
//! The configured `peer` becomes an optional allowlist; empty means "any tines
|
||||||
|
//! advertiser".
|
||||||
|
|
||||||
use std::boxed::Box;
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicU64, AtomicUsize, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
use tines_core::consts::MAX_FRAME;
|
use futures::StreamExt;
|
||||||
|
use tokio::sync::mpsc::{self as async_mpsc, UnboundedReceiver, UnboundedSender};
|
||||||
|
use tokio::time::Duration;
|
||||||
|
|
||||||
|
use bluer::{
|
||||||
|
adv,
|
||||||
|
l2cap::{self, SeqPacket, SeqPacketListener, SocketAddr},
|
||||||
|
monitor::{self, Monitor, MonitorEvent, MonitorHandle, Pattern, RssiSamplingPeriod},
|
||||||
|
Adapter, Address, AddressType, Session, Uuid, UuidExt,
|
||||||
|
};
|
||||||
|
|
||||||
|
use tines_core::consts::{MAX_FRAME, MAX_PEERS};
|
||||||
use tines_core::observation::NodeId;
|
use tines_core::observation::NodeId;
|
||||||
use tines_core::transport::{Event, FrameBuf, SendError, Transport};
|
use tines_core::transport::{Event, FrameBuf, SendError, Transport};
|
||||||
|
use tines_core::wire::frame_sender;
|
||||||
|
|
||||||
/// The radio I/O seam for [`BtTransport`].
|
/// The radio I/O seam for [`BtTransport`].
|
||||||
///
|
///
|
||||||
@@ -26,8 +72,8 @@ pub trait Radio {
|
|||||||
/// device's advertised [`NodeId`]).
|
/// device's advertised [`NodeId`]).
|
||||||
fn send(&mut self, me: NodeId, to: &NodeId, data: &[u8]) -> Result<(), RadioError>;
|
fn send(&mut self, me: NodeId, to: &NodeId, data: &[u8]) -> Result<(), RadioError>;
|
||||||
|
|
||||||
/// The current usable MTU in bytes (L2CAP Connection-Capable Channel when
|
/// The current usable MTU in bytes (the L2CAP Connection-Capable Channel's
|
||||||
/// available, else the negotiated ATT MTU on the GATT fallback).
|
/// negotiated MTU, clamped to [`MAX_FRAME`]).
|
||||||
fn mtu(&self) -> usize;
|
fn mtu(&self) -> usize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +86,16 @@ pub enum RadioEvent {
|
|||||||
/// The frame bytes carried by the event.
|
/// The frame bytes carried by the event.
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
},
|
},
|
||||||
/// The peer disconnected; any prior [`Event::Lost`] address is now gone.
|
/// A connection to `addr` became live.
|
||||||
Disconnected,
|
Contact {
|
||||||
|
/// The bound [`NodeId`] (the peer's advertised [`NodeId`]).
|
||||||
|
addr: NodeId,
|
||||||
|
},
|
||||||
|
/// The connection to `addr` ended (EOF, error, or adapter loss).
|
||||||
|
Lost {
|
||||||
|
/// The bound [`NodeId`] whose connection ended.
|
||||||
|
addr: NodeId,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a [`Radio::send`] call can fail.
|
/// Why a [`Radio::send`] call can fail.
|
||||||
@@ -95,21 +149,40 @@ pub fn is_central(me: NodeId, peer: NodeId) -> bool {
|
|||||||
me < peer
|
me < peer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the bound [`NodeId`] for an inbound BLE `addr` from the scan map.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the address was not seen advertising the tines service
|
||||||
|
/// UUID — an inbound connection from an unknown address binds to nothing and is
|
||||||
|
/// therefore rejected by the accept loop.
|
||||||
|
pub fn resolve_bound(
|
||||||
|
scan_map: &HashMap<Address, (NodeId, AddressType)>,
|
||||||
|
addr: &Address,
|
||||||
|
) -> Option<NodeId> {
|
||||||
|
scan_map.get(addr).map(|(peer, _)| *peer)
|
||||||
|
}
|
||||||
|
|
||||||
impl Transport for BtTransport {
|
impl Transport for BtTransport {
|
||||||
/// The peer's advertised [`NodeId`]; identity survives BLE address rotation.
|
/// The peer's advertised [`NodeId`]; identity survives BLE address rotation.
|
||||||
type Addr = NodeId;
|
type Addr = NodeId;
|
||||||
|
|
||||||
/// Poll for the next inbound event.
|
/// Poll for the next inbound event.
|
||||||
///
|
///
|
||||||
/// Inbound [`RadioEvent::Data`] frames whose `from` differs from the
|
/// Inbound [`RadioEvent::Data`] frames are checked with [`frame_sender`]:
|
||||||
/// connection's advertised [`NodeId`] are dropped here — a protocol
|
/// the frame's *declared* sender (peeked from the envelope) must equal the
|
||||||
/// violation (address spoofing) at the transport, so core never observes a
|
/// frame's claimed `from`, and the `from` must equal this transport's bound
|
||||||
/// mismatch. Frames too large for a [`FrameBuf`] are skipped rather than
|
/// [`NodeId`]. Both are dropped silently — the first is an address-spoofing
|
||||||
/// aborting the poll.
|
/// violation, the second a frame for a different connection — so core never
|
||||||
|
/// observes a mismatch. Frames too large for a [`FrameBuf`] are skipped
|
||||||
|
/// rather than aborting the poll.
|
||||||
fn poll(&mut self) -> Option<Event<Self::Addr>> {
|
fn poll(&mut self) -> Option<Event<Self::Addr>> {
|
||||||
loop {
|
loop {
|
||||||
match self.radio.poll() {
|
match self.radio.poll() {
|
||||||
Some(RadioEvent::Data { from, data }) => {
|
Some(RadioEvent::Data { from, data }) => {
|
||||||
|
// From-check: the frame's declared sender (peeked) must equal
|
||||||
|
// the claimed `from`. A mismatch is spoofing — drop it.
|
||||||
|
if frame_sender(&data) != Some(from) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if from != self.peer {
|
if from != self.peer {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -118,10 +191,15 @@ impl Transport for BtTransport {
|
|||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(RadioEvent::Disconnected) => {
|
Some(RadioEvent::Contact { addr }) => {
|
||||||
let peer = self.peer;
|
if addr == self.peer {
|
||||||
self.peer = [0u8; 16];
|
return Some(Event::Contact(addr));
|
||||||
return Some(Event::Lost(peer));
|
}
|
||||||
|
}
|
||||||
|
Some(RadioEvent::Lost { addr }) => {
|
||||||
|
if addr == self.peer {
|
||||||
|
return Some(Event::Lost(addr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => return None,
|
None => return None,
|
||||||
}
|
}
|
||||||
@@ -141,75 +219,59 @@ impl Transport for BtTransport {
|
|||||||
|
|
||||||
/// Maximum frame size in bytes this transport can send.
|
/// Maximum frame size in bytes this transport can send.
|
||||||
///
|
///
|
||||||
/// Reports the radio's real MTU (L2CAP CoC when available, else the ATT
|
/// Reports the radio's real MTU (the L2CAP Connection-Capable Channel's
|
||||||
/// MTU on the GATT fallback), clamped to `MAX_FRAME` per the
|
/// negotiated MTU, clamped to [`MAX_FRAME`]). Core folds summaries and
|
||||||
/// [`Transport`] contract. Core folds summaries and fragments down to this.
|
/// fragments down to this.
|
||||||
fn mtu(&self) -> usize {
|
fn mtu(&self) -> usize {
|
||||||
self.radio.mtu().min(MAX_FRAME)
|
self.radio.mtu().min(MAX_FRAME)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
/// Smallest MTU we are willing to report (ATT's default minimum is 23).
|
||||||
use std::sync::{
|
const MIN_MTU: usize = 23;
|
||||||
atomic::{AtomicUsize, Ordering},
|
|
||||||
Arc,
|
|
||||||
};
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
use futures::StreamExt;
|
/// L2CAP PSM the tines byte pipe runs on. Shared with the peer (agreed
|
||||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
/// out-of-band): both sides connect/listen on this PSM.
|
||||||
use tokio::sync::mpsc::{self as async_mpsc, UnboundedReceiver, UnboundedSender};
|
|
||||||
|
|
||||||
use bluer::{
|
|
||||||
adv,
|
|
||||||
l2cap::{SocketAddr, Stream as L2capStream},
|
|
||||||
monitor::{self, Monitor, MonitorEvent, Pattern, RssiSamplingPeriod},
|
|
||||||
AddressType, Uuid, UuidExt,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::adv::SERVICE_UUID;
|
|
||||||
|
|
||||||
/// Fixed L2CAP PSM for the tines byte pipe. Shared with the peer (agreed
|
|
||||||
/// out-of-band): the L2CAP stream connects on this PSM on both sides, so the
|
|
||||||
/// numerically smaller [`NodeId`] (the central) is the one that lands the
|
|
||||||
/// connection first.
|
|
||||||
const TINES_L2CAP_PSM: u16 = 0x008D;
|
const TINES_L2CAP_PSM: u16 = 0x008D;
|
||||||
|
|
||||||
/// A [`bluer`]/BlueZ-backed [`Radio`].
|
/// A [`bluer`]/BlueZ-backed [`Radio`].
|
||||||
///
|
///
|
||||||
/// Runs the async `bluer` D-Bus stack on a detached background thread: it
|
/// Runs the async `bluer` D-Bus stack on a detached background thread: it
|
||||||
/// powers the adapter, advertises this device's [`NodeId`] in service data,
|
/// powers the adapter, advertises this device's [`NodeId`] in service data,
|
||||||
/// monitors for a peer advertising the tines service UUID, and on discovery
|
/// monitors for peers advertising the tines service UUID, and — critically —
|
||||||
/// opens an L2CAP byte pipe on `TINES_L2CAP_PSM`. Inbound bytes are surfaced
|
/// *both* advertises and listens on `TINES_L2CAP_PSM` so either node can be
|
||||||
/// as [`RadioEvent::Data`] tagged with the peer's [`NodeId`] (which the
|
/// the initiator. On discovery it opens an L2CAP [`SeqPacket`] connection,
|
||||||
/// transport already knows — the BLE address is only how we route to it, and it
|
/// binds it to the peer's [`NodeId`] (from the advertisement), and replaces any
|
||||||
/// may rotate); outbound bytes are written to the pipe. The usable MTU is read
|
/// prior connection to that NodeId. Inbound bytes are surfaced as
|
||||||
/// from the L2CAP socket and reported through [`Radio::mtu`].
|
/// [`RadioEvent::Data`] tagged with the bound [`NodeId`]; outbound bytes are
|
||||||
|
/// routed to the matching connection.
|
||||||
///
|
///
|
||||||
/// The D-Bus session lives for the lifetime of this radio; [`BluerRadio`]
|
/// The D-Bus session lives for the lifetime of this radio; [`BluerRadio`]
|
||||||
/// detaches (rather than joins) its background thread on drop, because the run
|
/// detaches (rather than joins) its background thread on drop, because the run
|
||||||
/// loop blocks on BLE activity for as long as the transport is alive.
|
/// loop blocks on BLE activity for as long as the transport is alive.
|
||||||
///
|
|
||||||
/// This backend establishes the L2CAP pipe from the initiating side (the smaller
|
|
||||||
/// [`NodeId`], per [`is_central`]); callers construct the transport only for the
|
|
||||||
/// peer they intend to talk to.
|
|
||||||
pub struct BluerRadio {
|
pub struct BluerRadio {
|
||||||
event_rx: UnboundedReceiver<RadioEvent>,
|
event_rx: UnboundedReceiver<RadioEvent>,
|
||||||
send_tx: UnboundedSender<Vec<u8>>,
|
send_tx: UnboundedSender<(NodeId, Vec<u8>)>,
|
||||||
mtu: std::sync::Arc<AtomicUsize>,
|
mtu: Arc<AtomicUsize>,
|
||||||
handle: Option<thread::JoinHandle<()>>,
|
handle: Option<thread::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BluerRadio {
|
impl BluerRadio {
|
||||||
/// Start the `bluer`/BlueZ radio for a connection between `me` (this device)
|
/// Start the `bluer`/BlueZ radio for a connection between `me` (this
|
||||||
/// and `peer` (the peer's advertised [`NodeId`]).
|
/// device) and `peer` (the peer's advertised [`NodeId`]).
|
||||||
|
///
|
||||||
|
/// `peer` is this node's allowlist: only that NodeId's connections are
|
||||||
|
/// accepted. With an empty allowlist the transport accepts any tines
|
||||||
|
/// advertiser.
|
||||||
pub fn new(me: NodeId, peer: NodeId) -> Self {
|
pub fn new(me: NodeId, peer: NodeId) -> Self {
|
||||||
let (event_tx, event_rx) = async_mpsc::unbounded_channel::<RadioEvent>();
|
let (event_tx, event_rx) = async_mpsc::unbounded_channel::<RadioEvent>();
|
||||||
let (send_tx, send_rx) = async_mpsc::unbounded_channel::<Vec<u8>>();
|
let (send_tx, send_rx) = async_mpsc::unbounded_channel::<(NodeId, Vec<u8>)>();
|
||||||
// Default to the transport's frame limit until the L2CAP socket reports a
|
// Default to the transport's frame limit until the L2CAP socket reports a
|
||||||
// negotiated MTU; [`BtTransport::mtu`] clamps to MAX_FRAME regardless.
|
// negotiated MTU; [`BtTransport::mtu`] clamps to MAX_FRAME regardless.
|
||||||
let mtu = Arc::new(AtomicUsize::new(MAX_FRAME));
|
let mtu = Arc::new(AtomicUsize::new(MAX_FRAME));
|
||||||
let mtu_for_run = Arc::clone(&mtu);
|
let mtu_for_run = Arc::clone(&mtu);
|
||||||
|
// This node only accepts connections to `peer`.
|
||||||
|
let allow = Arc::new(BTreeSet::from([peer]));
|
||||||
|
|
||||||
let handle = thread::Builder::new()
|
let handle = thread::Builder::new()
|
||||||
.name(format!("tines-bt-bluer-{}", node_hex(peer)))
|
.name(format!("tines-bt-bluer-{}", node_hex(peer)))
|
||||||
@@ -218,7 +280,11 @@ impl BluerRadio {
|
|||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
.expect("failed to build bluer runtime");
|
.expect("failed to build bluer runtime");
|
||||||
let _ = rt.block_on(run(me, peer, event_tx, send_rx, mtu_for_run));
|
if let Err(e) = rt.block_on(run(me, allow, send_rx, mtu_for_run, event_tx)) {
|
||||||
|
log::error!("tines-bt: radio thread exited with error: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!("tines-bt: radio thread exited");
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.expect("failed to spawn bluer radio thread");
|
.expect("failed to spawn bluer radio thread");
|
||||||
|
|
||||||
@@ -244,9 +310,9 @@ impl Radio for BluerRadio {
|
|||||||
self.event_rx.try_recv().ok()
|
self.event_rx.try_recv().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send(&mut self, _me: NodeId, _to: &NodeId, data: &[u8]) -> Result<(), RadioError> {
|
fn send(&mut self, _me: NodeId, to: &NodeId, data: &[u8]) -> Result<(), RadioError> {
|
||||||
self.send_tx
|
self.send_tx
|
||||||
.send(data.to_vec())
|
.send((*to, data.to_vec()))
|
||||||
.map_err(|_| RadioError::Unreachable)
|
.map_err(|_| RadioError::Unreachable)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,124 +321,363 @@ impl Radio for BluerRadio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background async work: session, advertising, LE discovery, and the L2CAP
|
/// Per-connection state held while the connection is live.
|
||||||
/// byte pipe. Returns once the connection drops.
|
struct Conn {
|
||||||
|
socket: Arc<SeqPacket>,
|
||||||
|
/// Negotiated L2CAP MTU (bytes) — clamped at connect time.
|
||||||
|
mtu: u16,
|
||||||
|
/// Monotonic connection nonce; a newer connection to the same NodeId gets a
|
||||||
|
/// higher nonce, so a stale read loop never removes the live one.
|
||||||
|
nonce: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State shared across a single radio's background tasks.
|
||||||
|
///
|
||||||
|
/// The fields are individually `Arc`-wrapped so the per-task tasks share them;
|
||||||
|
/// the struct itself is also wrapped in `Arc` so it can be handed off to the
|
||||||
|
/// detached `tokio` tasks that own the BLE session.
|
||||||
|
struct SessionState {
|
||||||
|
/// Scanned `bt address → (bound NodeId, address type)`, for binding inbound
|
||||||
|
/// connections and dialing outbound ones.
|
||||||
|
scan_map: Arc<Mutex<HashMap<Address, (NodeId, AddressType)>>>,
|
||||||
|
/// Live connections, one per bound [`NodeId`] (a second replaces the first).
|
||||||
|
conns: Arc<Mutex<HashMap<NodeId, Conn>>>,
|
||||||
|
/// The negotiated L2CAP MTU.
|
||||||
|
mtu: Arc<AtomicUsize>,
|
||||||
|
/// Allowed [`NodeId`]s; empty means "any tines advertiser".
|
||||||
|
allow: Arc<BTreeSet<NodeId>>,
|
||||||
|
/// Monotonic counter assigning a unique nonce to each connection.
|
||||||
|
nonces: Arc<AtomicU64>,
|
||||||
|
/// Fan-out channel for events; owned by the read loops and the write loop.
|
||||||
|
events_tx: UnboundedSender<RadioEvent>,
|
||||||
|
/// The adapter — used to look up discovered devices' service data.
|
||||||
|
adapter: Arc<Adapter>,
|
||||||
|
/// This device's advertised [`NodeId`].
|
||||||
|
me: NodeId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background async work: session setup, advertising, LE discovery, listening,
|
||||||
|
/// connecting, and the L2CAP byte pipes. Returns once every task has exited.
|
||||||
async fn run(
|
async fn run(
|
||||||
me: NodeId,
|
me: NodeId,
|
||||||
peer: NodeId,
|
allow: Arc<BTreeSet<NodeId>>,
|
||||||
event_tx: UnboundedSender<RadioEvent>,
|
outbound: UnboundedReceiver<(NodeId, Vec<u8>)>,
|
||||||
send_rx: UnboundedReceiver<Vec<u8>>,
|
|
||||||
mtu: Arc<AtomicUsize>,
|
mtu: Arc<AtomicUsize>,
|
||||||
|
events_tx: UnboundedSender<RadioEvent>,
|
||||||
) -> Result<(), bluer::Error> {
|
) -> Result<(), bluer::Error> {
|
||||||
let session = bluer::Session::new().await?;
|
let session = Session::new().await?;
|
||||||
let adapter = session.default_adapter().await?;
|
let adapter = Arc::new(session.default_adapter().await?);
|
||||||
|
let state = Arc::new(SessionState {
|
||||||
|
scan_map: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
conns: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
mtu: Arc::clone(&mtu),
|
||||||
|
allow,
|
||||||
|
nonces: Arc::new(AtomicU64::new(1)),
|
||||||
|
events_tx,
|
||||||
|
adapter: Arc::clone(&adapter),
|
||||||
|
me,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Power the adapter. A failure here is fatal: nothing else can work, so it
|
||||||
|
// is returned (and logged by the radio thread) rather than swallowed.
|
||||||
if let Err(e) = adapter.set_powered(true).await {
|
if let Err(e) = adapter.set_powered(true).await {
|
||||||
log::warn!("tines-bt: failed to power adapter: {e:?}");
|
log::error!("tines-bt: failed to power the Bluetooth adapter: {e}");
|
||||||
return Ok(());
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advertise our own NodeId in service data so a peer can recognise us and
|
// Advertise our own NodeId in service data so a peer can recognise us and
|
||||||
// learn our identity before connecting (address rotation aside).
|
// learn our identity before connecting (address rotation aside). Advertising
|
||||||
|
// is symmetric — the peer will connect to us on our listener if we cannot
|
||||||
|
// dial — so a failure is surfaced at error level but not fatal.
|
||||||
let advertisement = adv::Advertisement {
|
let advertisement = adv::Advertisement {
|
||||||
advertisement_type: adv::Type::Peripheral,
|
advertisement_type: adv::Type::Peripheral,
|
||||||
service_uuids: BTreeSet::from([Uuid::from_u16(SERVICE_UUID)]),
|
service_uuids: BTreeSet::from([Uuid::from_u16(crate::adv::SERVICE_UUID)]),
|
||||||
service_data: BTreeMap::from([(Uuid::from_u16(SERVICE_UUID), me.to_vec())]),
|
service_data: BTreeMap::from([(Uuid::from_u16(crate::adv::SERVICE_UUID), me.to_vec())]),
|
||||||
discoverable: Some(true),
|
discoverable: Some(true),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
if adapter.advertise(advertisement).await.is_err() {
|
if adapter.advertise(advertisement).await.is_err() {
|
||||||
log::warn!("tines-bt: failed to advertise; continuing with discovery");
|
log::error!("tines-bt: advertising failed; continuing with listening and discovery");
|
||||||
}
|
}
|
||||||
|
|
||||||
// LE advertisement monitor: a peer advertising the tines service UUID. The
|
// LE advertisement monitor: a peer advertising the tines service UUID. The
|
||||||
// monitor yields the peer's (rotating) BLE address; the peer's identity is
|
// monitor yields the peer's (rotating) BLE address; we look the peer up as
|
||||||
// the [`NodeId`] we already know (`peer`), which is what tags inbound frames.
|
// a `Device`, decode its [`NodeId`] (UUID-checked), and record it.
|
||||||
let manager = adapter.monitor().await?;
|
let manager = adapter.monitor().await?;
|
||||||
let mut monitor = manager
|
let monitor = manager
|
||||||
.register(Monitor {
|
.register(Monitor {
|
||||||
monitor_type: monitor::Type::OrPatterns,
|
monitor_type: monitor::Type::OrPatterns,
|
||||||
rssi_sampling_period: Some(RssiSamplingPeriod::First),
|
rssi_sampling_period: Some(RssiSamplingPeriod::First),
|
||||||
patterns: Some(vec![Pattern {
|
patterns: Some(vec![Pattern {
|
||||||
data_type: 0x16, // Service Data — 16-bit UUID
|
data_type: 0x16, // Service Data — 16-bit UUID
|
||||||
start_position: 0,
|
start_position: 0,
|
||||||
content: SERVICE_UUID.to_le_bytes().to_vec(),
|
content: crate::adv::SERVICE_UUID.to_le_bytes().to_vec(),
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
while let Some(event) = monitor.next().await {
|
// Bind the listener (the accept role) on our PSM. A failure to bind means we
|
||||||
if let MonitorEvent::DeviceFound(found) = event {
|
// cannot accept, so it is fatal.
|
||||||
let addr = found.device;
|
let listener = l2cap::SeqPacketListener::bind(SocketAddr::new(
|
||||||
let sa = SocketAddr::new(addr, AddressType::LePublic, TINES_L2CAP_PSM);
|
Address::any(),
|
||||||
match L2capStream::connect(sa).await {
|
AddressType::LePublic,
|
||||||
Ok(stream) => {
|
TINES_L2CAP_PSM,
|
||||||
if let Ok(mtu_bytes) = stream.as_ref().recv_mtu() {
|
))
|
||||||
let m = mtu_bytes as usize;
|
.await
|
||||||
mtu.store(m.clamp(MIN_MTU, MAX_FRAME), Ordering::Relaxed);
|
.map_err(|e| {
|
||||||
}
|
log::error!(
|
||||||
let (read_half, write_half) = stream.into_split();
|
"tines-bt: failed to bind the SeqPacket listener on PSM {TINES_L2CAP_PSM:#06X}: {e}"
|
||||||
let read_join = tokio::spawn(async move {
|
);
|
||||||
read_loop(peer, read_half, event_tx).await;
|
e
|
||||||
});
|
})?;
|
||||||
let write_join = tokio::spawn(async move {
|
|
||||||
write_loop(write_half, send_rx).await;
|
let monitor_state = Arc::clone(&state);
|
||||||
});
|
let accept_state = Arc::clone(&state);
|
||||||
// The pipe is up. Block until either loop ends (disconnect),
|
let connect_state = Arc::clone(&state);
|
||||||
// then tear down so the runtime can stop.
|
let write_state = Arc::clone(&state);
|
||||||
tokio::select! {
|
let monitor_join = tokio::spawn(async move {
|
||||||
_ = read_join => {}
|
monitor_task(monitor, monitor_state).await;
|
||||||
_ = write_join => {}
|
});
|
||||||
}
|
let accept_join = tokio::spawn(async move {
|
||||||
return Ok(());
|
accept_loop(listener, accept_state).await;
|
||||||
}
|
});
|
||||||
Err(e) => {
|
let connect_join = tokio::spawn(async move {
|
||||||
log::trace!("tines-bt: connect to {addr:?} failed: {e:?}");
|
connect_loop(connect_state).await;
|
||||||
}
|
});
|
||||||
}
|
let write_join = tokio::spawn(async move {
|
||||||
}
|
write_loop(write_state, outbound).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = monitor_join => {}
|
||||||
|
_ = accept_join => {}
|
||||||
|
_ = connect_join => {}
|
||||||
|
_ = write_join => {}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read inbound bytes off the L2CAP pipe and emit them as [`RadioEvent::Data`],
|
/// Drive the LE advertisement monitor, recording discovered tines peers in the
|
||||||
/// tagged with the peer's [`NodeId`].
|
/// scan map and forgetting them when they stop advertising.
|
||||||
async fn read_loop<P: AsyncRead + Unpin>(
|
async fn monitor_task(mut monitor: MonitorHandle, state: Arc<SessionState>) {
|
||||||
peer: NodeId,
|
while let Some(event) = monitor.next().await {
|
||||||
mut read_half: P,
|
match event {
|
||||||
tx: UnboundedSender<RadioEvent>,
|
MonitorEvent::DeviceFound(found) => {
|
||||||
) {
|
let addr = found.device;
|
||||||
let mut buf = vec![0u8; MAX_FRAME];
|
if let Some((peer, atype)) = discover_peer(addr, state.clone()).await {
|
||||||
loop {
|
state.scan_map.lock().unwrap().insert(addr, (peer, atype));
|
||||||
match read_half.read(&mut buf).await {
|
log::trace!("tines-bt: discovered tines peer {peer:?} at {addr}");
|
||||||
Ok(0) => break,
|
|
||||||
Ok(n) => {
|
|
||||||
if tx
|
|
||||||
.send(RadioEvent::Data {
|
|
||||||
from: peer,
|
|
||||||
data: buf[..n].to_vec(),
|
|
||||||
})
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
MonitorEvent::DeviceLost(found) => {
|
||||||
|
state.scan_map.lock().unwrap().remove(&found.device);
|
||||||
|
}
|
||||||
|
// Other monitor events (RSSI changes, etc.) are ignored.
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain outbound bytes off the queue and write them to the L2CAP pipe.
|
/// Look a discovered `addr` up as a `Device`, decode its [`NodeId`] (checking
|
||||||
async fn write_loop<P: AsyncWrite + Unpin>(mut write_half: P, mut rx: UnboundedReceiver<Vec<u8>>) {
|
/// the tines service UUID and this node's allowlist), and return it with the
|
||||||
while let Some(bytes) = rx.recv().await {
|
/// device's address type. Returns `None` for non-tines or disallowed advertisers.
|
||||||
if write_half.write_all(&bytes).await.is_err() {
|
async fn discover_peer(addr: Address, state: Arc<SessionState>) -> Option<(NodeId, AddressType)> {
|
||||||
|
let device = state.adapter.device(addr).ok()?;
|
||||||
|
let service_data = match device.service_data().await.ok()? {
|
||||||
|
Some(service_data) => service_data,
|
||||||
|
None => return None,
|
||||||
|
};
|
||||||
|
let peer = crate::adv::decode_ad_node_id(&service_data, state.me, &state.allow)?;
|
||||||
|
let atype = device.address_type().await.ok()?;
|
||||||
|
Some((peer, atype))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accept role: bind `listener` on our PSM, and for each accepted connection
|
||||||
|
/// resolve the peer's bound [`NodeId`] and replace any prior connection to it.
|
||||||
|
async fn accept_loop(listener: SeqPacketListener, state: Arc<SessionState>) {
|
||||||
|
loop {
|
||||||
|
match listener.accept().await {
|
||||||
|
Ok((socket, peer_sa)) => {
|
||||||
|
let peer_addr = peer_sa.addr;
|
||||||
|
let bound = resolve_bound(&state.scan_map.lock().unwrap(), &peer_addr);
|
||||||
|
match bound {
|
||||||
|
Some(peer) => {
|
||||||
|
if state.allow.contains(&peer) {
|
||||||
|
// A second connection to an already-bound NodeId
|
||||||
|
// replaces the first (handled inside register_and_read);
|
||||||
|
// a brand-new peer is only admitted while under the
|
||||||
|
// concurrent-connection cap.
|
||||||
|
let under_cap = state.conns.lock().unwrap().contains_key(&peer)
|
||||||
|
|| state.conns.lock().unwrap().len() < MAX_PEERS;
|
||||||
|
if under_cap {
|
||||||
|
register_and_read(state.clone(), peer, socket).await;
|
||||||
|
log::trace!("tines-bt: accepted connection from {peer:?} (bound)");
|
||||||
|
} else {
|
||||||
|
log::trace!("tines-bt: peer {peer:?} over MAX_PEERS ({MAX_PEERS}); closing new connection");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::trace!("tines-bt: rejecting inbound from {peer:?} (not allowed)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log::trace!("tines-bt: rejecting inbound from unknown address {peer_addr}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("tines-bt: SeqPacketListener accept error: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The central/initiator role: periodically dial peers we have discovered that
|
||||||
|
/// this node should connect to (smaller [`NodeId`]) and are not already live.
|
||||||
|
async fn connect_loop(state: Arc<SessionState>) {
|
||||||
|
let mut ticker = tokio::time::interval(Duration::from_millis(500));
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
|
||||||
|
// Collect eligible candidates without holding any lock across an await.
|
||||||
|
let candidates = {
|
||||||
|
let scan = state.scan_map.lock().unwrap();
|
||||||
|
let allow = &*state.allow;
|
||||||
|
scan.iter()
|
||||||
|
.filter(|(_addr, (peer, _))| ad_eligible(*peer, state.me, allow))
|
||||||
|
.map(|(addr, (peer, atype))| (*addr, *peer, *atype))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (addr, peer, atype) in candidates {
|
||||||
|
if !is_central(state.me, peer) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if state.conns.lock().unwrap().contains_key(&peer) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if state.conns.lock().unwrap().len() >= MAX_PEERS {
|
||||||
|
log::trace!(
|
||||||
|
"tines-bt: connected peers over MAX_PEERS ({MAX_PEERS}); skipping {peer:?}"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sa = SocketAddr::new(addr, atype, TINES_L2CAP_PSM);
|
||||||
|
match SeqPacket::connect(sa).await {
|
||||||
|
Ok(socket) => {
|
||||||
|
register_and_read(state.clone(), peer, socket).await;
|
||||||
|
log::trace!("tines-bt: connected to {peer:?} at {addr} (central)");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::trace!("tines-bt: connect to {peer:?} failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert `socket` for `peer` into the connection map, replacing any prior
|
||||||
|
/// connection, and return its [`Arc<SeqPacket>`] and nonce.
|
||||||
|
async fn register_connection(
|
||||||
|
state: Arc<SessionState>,
|
||||||
|
peer: NodeId,
|
||||||
|
socket: SeqPacket,
|
||||||
|
) -> (Arc<SeqPacket>, u64) {
|
||||||
|
let mtu = mtu_bytes(&socket);
|
||||||
|
let nonce = state.nonces.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let socket = Arc::new(socket);
|
||||||
|
if let Some(old) = state.conns.lock().unwrap().insert(
|
||||||
|
peer,
|
||||||
|
Conn {
|
||||||
|
socket: socket.clone(),
|
||||||
|
mtu,
|
||||||
|
nonce,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
// The replaced connection is no longer referenced here; its read loop's
|
||||||
|
// nonce check surfaces the Lost event for the stale connection.
|
||||||
|
drop(old.socket);
|
||||||
|
}
|
||||||
|
state.mtu.store(mtu as usize, Ordering::Relaxed);
|
||||||
|
(socket, nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register `socket` for `peer` (replacing any prior connection) and spawn its
|
||||||
|
/// read loop.
|
||||||
|
async fn register_and_read(state: Arc<SessionState>, peer: NodeId, socket: SeqPacket) {
|
||||||
|
let (conn, nonce) = register_connection(state.clone(), peer, socket).await;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
read_loop(peer, conn, state, nonce).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The read loop for a single connection: read one SDU at a time (one frame
|
||||||
|
/// each) and emit [`RadioEvent::Data`]. On EOF/error it removes the connection
|
||||||
|
/// unless a newer connection with a higher nonce has replaced it, then emits
|
||||||
|
/// [`RadioEvent::Lost`].
|
||||||
|
async fn read_loop(peer: NodeId, socket: Arc<SeqPacket>, state: Arc<SessionState>, nonce: u64) {
|
||||||
|
let mut buf = vec![0u8; state.mtu.load(Ordering::Relaxed)];
|
||||||
|
while let Ok(n) = socket.recv(&mut buf).await {
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if state
|
||||||
|
.events_tx
|
||||||
|
.send(RadioEvent::Data {
|
||||||
|
from: peer,
|
||||||
|
data: buf[..n].to_vec(),
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection ended. Remove it only if it is still the live one for this
|
||||||
|
// NodeId (a newer connection replaced it, so leave that one in place).
|
||||||
|
let mut guard = state.conns.lock().unwrap();
|
||||||
|
if guard.get(&peer).map(|c| c.nonce) == Some(nonce) {
|
||||||
|
guard.remove(&peer);
|
||||||
|
let _ = state.events_tx.send(RadioEvent::Lost { addr: peer });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Smallest MTU we are willing to report (ATT's default minimum is 23).
|
/// Drain outbound frames and write each to its destination connection.
|
||||||
const MIN_MTU: usize = 23;
|
async fn write_loop(state: Arc<SessionState>, mut outbound: UnboundedReceiver<(NodeId, Vec<u8>)>) {
|
||||||
|
while let Some((to, data)) = outbound.recv().await {
|
||||||
|
// Read the connection (dropping the guard before awaiting so it isn't
|
||||||
|
// held across the send's await point — a MutexGuard is not Send).
|
||||||
|
let (socket, mtu) = match state.conns.lock().unwrap().get(&to) {
|
||||||
|
Some(conn) => (Some(Arc::clone(&conn.socket)), conn.mtu as usize),
|
||||||
|
None => (None, 0),
|
||||||
|
};
|
||||||
|
if let Some(socket) = socket {
|
||||||
|
if socket.send(&data[..mtu.min(data.len())]).await.is_err() {
|
||||||
|
log::trace!("tines-bt: send to {to:?} failed");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::trace!("tines-bt: no connection to {to:?}; dropping frame");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the advertisement for `peer` is eligible for this node to consider.
|
||||||
|
///
|
||||||
|
/// A node never dials itself, and only considers peers in the allowlist (an
|
||||||
|
/// empty allowlist accepts any tines advertiser — the NodeId check is already
|
||||||
|
/// done by [`crate::adv::decode_ad_node_id`]).
|
||||||
|
fn ad_eligible(peer: NodeId, me: NodeId, allow: &BTreeSet<NodeId>) -> bool {
|
||||||
|
peer != me && (allow.is_empty() || allow.contains(&peer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The negotiated L2CAP MTU for `socket`, clamped to our limits.
|
||||||
|
fn mtu_bytes(socket: &SeqPacket) -> u16 {
|
||||||
|
socket
|
||||||
|
.send_mtu()
|
||||||
|
.map(|m| m.clamp(MIN_MTU, MAX_FRAME) as u16)
|
||||||
|
.unwrap_or(MAX_FRAME as u16)
|
||||||
|
}
|
||||||
|
|
||||||
/// Render a [`NodeId`] as hex for thread naming (avoids pulling in a hex crate).
|
/// Render a [`NodeId`] as hex for thread naming (avoids pulling in a hex crate).
|
||||||
fn node_hex(node: NodeId) -> String {
|
fn node_hex(node: NodeId) -> String {
|
||||||
|
|||||||
+38
-23
@@ -73,55 +73,70 @@ fn advertising_payload_roundtrips_node_id() {
|
|||||||
assert_eq!(adv::decode_node_id(&p), Some(node));
|
assert_eq!(adv::decode_node_id(&p), Some(node));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A framed envelope: one tag byte, then the 16-byte `from`. This is the layout
|
||||||
|
/// `frame_sender` peeks — the transport delivers this whole envelope to core
|
||||||
|
/// (which decodes it), so the `from` must be stamped with the sender's id.
|
||||||
|
fn frame(from: NodeId, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(1 + 16 + payload.len());
|
||||||
|
b.push(0u8); // version/tag byte
|
||||||
|
b.extend_from_slice(&from);
|
||||||
|
b.extend_from_slice(payload);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_mismatch_is_dropped() {
|
fn from_mismatch_is_dropped() {
|
||||||
let me: NodeId = [1u8; 16];
|
let me: NodeId = [1u8; 16];
|
||||||
let peer: NodeId = [2u8; 16]; // the connection's advertised NodeId
|
let peer: NodeId = [2u8; 16]; // the connection's advertised NodeId (bound Addr)
|
||||||
let spoof: NodeId = [9u8; 16]; // claimed source that does not match `peer`
|
let spoof: NodeId = [9u8; 16]; // a declared sender that does not match `peer`
|
||||||
|
|
||||||
// A single spoofed frame: the transport drops it and emits nothing.
|
// A legitimate frame from `peer` (envelope stamped with `peer`) is delivered
|
||||||
let mut radio = MockRadio::new(23);
|
// unchanged, envelope included — core decodes the whole thing.
|
||||||
radio.push(RadioEvent::Data {
|
|
||||||
from: spoof,
|
|
||||||
data: vec![0xCA, 0xFE],
|
|
||||||
});
|
|
||||||
let mut t = BtTransport::new(me, peer, radio);
|
|
||||||
assert_eq!(
|
|
||||||
t.poll(),
|
|
||||||
None,
|
|
||||||
"a frame from a non-advertised id yields no Event::Frame"
|
|
||||||
);
|
|
||||||
|
|
||||||
// A legitimate frame from `peer` is delivered unchanged.
|
|
||||||
let mut good = MockRadio::new(23);
|
let mut good = MockRadio::new(23);
|
||||||
good.push(RadioEvent::Data {
|
good.push(RadioEvent::Data {
|
||||||
from: peer,
|
from: peer,
|
||||||
data: vec![0x01, 0x02],
|
data: frame(peer, &[0x01, 0x02]),
|
||||||
});
|
});
|
||||||
let mut t = BtTransport::new(me, peer, good);
|
let mut t = BtTransport::new(me, peer, good);
|
||||||
match t.poll() {
|
match t.poll() {
|
||||||
Some(Event::Frame(from, data)) => {
|
Some(Event::Frame(from, data)) => {
|
||||||
assert_eq!(from, peer);
|
assert_eq!(from, peer);
|
||||||
assert_eq!(&data[..], &[0x01, 0x02]);
|
assert_eq!(&data[..], &frame(peer, &[0x01, 0x02])[..]);
|
||||||
}
|
}
|
||||||
other => panic!("expected a Frame from {peer:?}, got {other:?}"),
|
other => panic!("expected a Frame from {peer:?}, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// And a spoofed frame sitting before a legit one is simply skipped.
|
// A frame whose envelope `from` (peeked by frame_sender) is `spoof`, not the
|
||||||
|
// bound peer, is dropped by the from-check — even though the event claims the
|
||||||
|
// bound peer as its source.
|
||||||
|
let mut mismatched = MockRadio::new(23);
|
||||||
|
mismatched.push(RadioEvent::Data {
|
||||||
|
from: peer,
|
||||||
|
data: frame(spoof, &[0xCA, 0xFE]),
|
||||||
|
});
|
||||||
|
let mut t = BtTransport::new(me, peer, mismatched);
|
||||||
|
assert_eq!(
|
||||||
|
t.poll(),
|
||||||
|
None,
|
||||||
|
"a frame whose decoded `from` differs from the bound Addr is dropped"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a mismatched frame sitting before a legit one is simply skipped; the
|
||||||
|
// legit one is delivered.
|
||||||
let mut mixed = MockRadio::new(23);
|
let mut mixed = MockRadio::new(23);
|
||||||
mixed.push(RadioEvent::Data {
|
mixed.push(RadioEvent::Data {
|
||||||
from: spoof,
|
from: peer,
|
||||||
data: vec![0xFF],
|
data: frame(spoof, &[0xFF]),
|
||||||
});
|
});
|
||||||
mixed.push(RadioEvent::Data {
|
mixed.push(RadioEvent::Data {
|
||||||
from: peer,
|
from: peer,
|
||||||
data: vec![0x03],
|
data: frame(peer, &[0x03]),
|
||||||
});
|
});
|
||||||
let mut t = BtTransport::new(me, peer, mixed);
|
let mut t = BtTransport::new(me, peer, mixed);
|
||||||
match t.poll() {
|
match t.poll() {
|
||||||
Some(Event::Frame(from, data)) => {
|
Some(Event::Frame(from, data)) => {
|
||||||
assert_eq!(from, peer);
|
assert_eq!(from, peer);
|
||||||
assert_eq!(&data[..], &[0x03]);
|
assert_eq!(&data[..], &frame(peer, &[0x03])[..]);
|
||||||
}
|
}
|
||||||
other => panic!("expected the Frame from {peer:?}, got {other:?}"),
|
other => panic!("expected the Frame from {peer:?}, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
//! Integration tests for the tines-bt transport, covering the F3 properties:
|
||||||
|
//! the `from`/`Addr` check, `Contact`/`Lost`, multi-peer filtering, NodeId
|
||||||
|
//! binding (UUID-checked advertisement + inbound-address lookup), and the
|
||||||
|
//! central-peripheral role decision.
|
||||||
|
//!
|
||||||
|
//! Every test uses a mock [`Radio`] and never touches a real BLE radio, so the
|
||||||
|
//! suite runs on a CI box with no Bluetooth stack.
|
||||||
|
|
||||||
|
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||||
|
|
||||||
|
use tines_bt::{adv, resolve_bound, BtTransport, Radio, RadioError, RadioEvent};
|
||||||
|
use tines_core::observation::NodeId;
|
||||||
|
use tines_core::transport::{Event, Transport};
|
||||||
|
|
||||||
|
use bluer::{Address, AddressType, Uuid, UuidExt};
|
||||||
|
|
||||||
|
/// A canned [`Radio`] backed by queued events and a configurable MTU.
|
||||||
|
struct MockRadio {
|
||||||
|
events: VecDeque<RadioEvent>,
|
||||||
|
_sent: Vec<(NodeId, Vec<u8>)>,
|
||||||
|
mtu: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockRadio {
|
||||||
|
fn new(mtu: usize) -> Self {
|
||||||
|
MockRadio {
|
||||||
|
events: VecDeque::new(),
|
||||||
|
_sent: Vec::new(),
|
||||||
|
mtu,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, ev: RadioEvent) {
|
||||||
|
self.events.push_back(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Radio for MockRadio {
|
||||||
|
fn poll(&mut self) -> Option<RadioEvent> {
|
||||||
|
self.events.pop_front()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&mut self, _me: NodeId, to: &NodeId, data: &[u8]) -> Result<(), RadioError> {
|
||||||
|
self._sent.push((*to, data.to_vec()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mtu(&self) -> usize {
|
||||||
|
self.mtu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MockRadio {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Drain the queue so a caller that only polls a couple of times does not
|
||||||
|
// leave events stranded behind the dead drop.
|
||||||
|
self.events.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimal frame: one leading tag byte, then the 16-byte `from`.
|
||||||
|
/// `frame_sender` reads `from` from bytes 1..17, so this matches the wire.
|
||||||
|
fn frame(from: NodeId) -> Vec<u8> {
|
||||||
|
let mut b = vec![0u8; 1 + 16];
|
||||||
|
b[1..1 + 16].copy_from_slice(&from);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_check_and_peer_filter_drop_frames() {
|
||||||
|
let me: NodeId = [1u8; 16];
|
||||||
|
let peer: NodeId = [2u8; 16]; // the connection's advertised NodeId (bound Addr)
|
||||||
|
let spoof: NodeId = [9u8; 16]; // a declared sender that is not the bound peer
|
||||||
|
let other: NodeId = [3u8; 16]; // a different bound peer entirely
|
||||||
|
|
||||||
|
// A clean frame from `peer` is delivered unchanged.
|
||||||
|
let mut good = MockRadio::new(23);
|
||||||
|
good.push(RadioEvent::Data {
|
||||||
|
from: peer,
|
||||||
|
data: frame(peer),
|
||||||
|
});
|
||||||
|
let mut t = BtTransport::new(me, peer, good);
|
||||||
|
match t.poll() {
|
||||||
|
Some(Event::Frame(from, data)) => {
|
||||||
|
assert_eq!(from, peer);
|
||||||
|
assert_eq!(&data[..], &frame(peer)[..]);
|
||||||
|
}
|
||||||
|
other => panic!("expected a Frame from {peer:?}, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// From-check violation: the frame's declared `from` (in the event) is the
|
||||||
|
// bound peer, but the frame's actual `from` bytes say `spoof`. This is an
|
||||||
|
// address-spoofing violation — dropped via the peeked `frame_sender`.
|
||||||
|
let mut spoofed = MockRadio::new(23);
|
||||||
|
spoofed.push(RadioEvent::Data {
|
||||||
|
from: peer,
|
||||||
|
data: frame(spoof),
|
||||||
|
});
|
||||||
|
let mut t = BtTransport::new(me, peer, spoofed);
|
||||||
|
assert_eq!(
|
||||||
|
t.poll(),
|
||||||
|
None,
|
||||||
|
"a frame whose decoded `from` differs from the bound Addr is dropped"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Peer filter: a frame tagged as coming from a *different* bound peer is
|
||||||
|
// dropped (a multi-peer radio emits events for every connection; the
|
||||||
|
// transport only surfaces its own bound peer).
|
||||||
|
let mut other_peer = MockRadio::new(23);
|
||||||
|
other_peer.push(RadioEvent::Data {
|
||||||
|
from: other,
|
||||||
|
data: frame(other),
|
||||||
|
});
|
||||||
|
let mut t = BtTransport::new(me, peer, other_peer);
|
||||||
|
assert_eq!(t.poll(), None, "a frame from a non-bound peer is dropped");
|
||||||
|
|
||||||
|
// A spoofed frame sitting before a good one is simply skipped; the good one
|
||||||
|
// is delivered.
|
||||||
|
let mut mixed = MockRadio::new(23);
|
||||||
|
mixed.push(RadioEvent::Data {
|
||||||
|
from: peer,
|
||||||
|
data: frame(spoof),
|
||||||
|
});
|
||||||
|
mixed.push(RadioEvent::Data {
|
||||||
|
from: peer,
|
||||||
|
data: frame(peer),
|
||||||
|
});
|
||||||
|
let mut t = BtTransport::new(me, peer, mixed);
|
||||||
|
match t.poll() {
|
||||||
|
Some(Event::Frame(from, data)) => {
|
||||||
|
assert_eq!(from, peer);
|
||||||
|
assert_eq!(&data[..], &frame(peer)[..]);
|
||||||
|
}
|
||||||
|
other => panic!("expected the clean Frame from {peer:?}, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_and_lost_emitted() {
|
||||||
|
let me: NodeId = [1u8; 16];
|
||||||
|
let peer: NodeId = [2u8; 16];
|
||||||
|
|
||||||
|
let mut radio = MockRadio::new(23);
|
||||||
|
radio.push(RadioEvent::Contact { addr: peer });
|
||||||
|
radio.push(RadioEvent::Data {
|
||||||
|
from: peer,
|
||||||
|
data: frame(peer),
|
||||||
|
});
|
||||||
|
radio.push(RadioEvent::Lost { addr: peer });
|
||||||
|
|
||||||
|
let mut t = BtTransport::new(me, peer, radio);
|
||||||
|
|
||||||
|
assert_eq!(t.poll(), Some(Event::Contact(peer)), "up → Contact");
|
||||||
|
assert!(matches!(t.poll(), Some(Event::Frame(..))), "a frame passes");
|
||||||
|
assert_eq!(t.poll(), Some(Event::Lost(peer)), "end → Lost");
|
||||||
|
assert_eq!(t.poll(), None, "no more events once the connection is gone");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_boundaries_preserved_at_transport() {
|
||||||
|
// Two back-to-back frames arrive as two separate Data events; the
|
||||||
|
// transport must surface them as two separate Frames, never merged.
|
||||||
|
let me: NodeId = [1u8; 16];
|
||||||
|
let peer: NodeId = [2u8; 16];
|
||||||
|
let frame_a = frame(peer);
|
||||||
|
let mut frame_b = frame(peer);
|
||||||
|
frame_b[0] = 0xFF; // differ from frame_a via the tag byte, so `from` stays == peer
|
||||||
|
let a_expected = frame_a.clone();
|
||||||
|
let b_expected = frame_b.clone();
|
||||||
|
|
||||||
|
let mut radio = MockRadio::new(23);
|
||||||
|
radio.push(RadioEvent::Data {
|
||||||
|
from: peer,
|
||||||
|
data: frame_a,
|
||||||
|
});
|
||||||
|
radio.push(RadioEvent::Data {
|
||||||
|
from: peer,
|
||||||
|
data: frame_b,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut t = BtTransport::new(me, peer, radio);
|
||||||
|
|
||||||
|
match t.poll() {
|
||||||
|
Some(Event::Frame(_, a)) => assert_eq!(&a[..], &a_expected[..]),
|
||||||
|
other => panic!("expected the first frame, got {other:?}"),
|
||||||
|
}
|
||||||
|
match t.poll() {
|
||||||
|
Some(Event::Frame(_, b)) => assert_eq!(&b[..], &b_expected[..]),
|
||||||
|
other => panic!("expected the second frame, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(t.poll(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_central_by_node_id_order() {
|
||||||
|
let a: NodeId = [0u8; 16];
|
||||||
|
let b: NodeId = [1u8; 16];
|
||||||
|
let big: NodeId = [255u8; 16];
|
||||||
|
assert!(tines_bt::is_central(a, b)); // smaller dials
|
||||||
|
assert!(!tines_bt::is_central(b, a));
|
||||||
|
assert!(tines_bt::is_central(b, big));
|
||||||
|
assert!(!tines_bt::is_central(big, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_bound_rejects_unknown_inbound() {
|
||||||
|
// An empty scan map: no address is eligible, so an inbound from an unknown
|
||||||
|
// address binds to nothing and must be rejected.
|
||||||
|
let empty: HashMap<Address, (NodeId, AddressType)> = HashMap::new();
|
||||||
|
let unknown = Address::new([0xAA; 6]);
|
||||||
|
assert_eq!(resolve_bound(&empty, &unknown), None);
|
||||||
|
|
||||||
|
// A peer we discovered and bound: its own address resolves.
|
||||||
|
let node: NodeId = [4u8; 16];
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert(Address::new([0xBB; 6]), (node, AddressType::LePublic));
|
||||||
|
let addr = Address::new([0xBB; 6]);
|
||||||
|
assert_eq!(resolve_bound(&map, &addr), Some(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_ad_node_id_requires_tines_uuid() {
|
||||||
|
let me: NodeId = [7u8; 16];
|
||||||
|
let node: NodeId = [5u8; 16];
|
||||||
|
let allow: BTreeSet<NodeId> = BTreeSet::new(); // empty = any tines advertiser
|
||||||
|
|
||||||
|
// An EIR-less service-data blob (wrong UUID) is rejected.
|
||||||
|
let wrong_uuid: HashMap<Uuid, Vec<u8>> =
|
||||||
|
HashMap::from([(Uuid::from_u16(0x180F), node.to_vec())]);
|
||||||
|
assert_eq!(adv::decode_ad_node_id(&wrong_uuid, me, &allow), None);
|
||||||
|
|
||||||
|
// No service data at all is rejected.
|
||||||
|
let empty: HashMap<Uuid, Vec<u8>> = HashMap::new();
|
||||||
|
assert_eq!(adv::decode_ad_node_id(&empty, me, &allow), None);
|
||||||
|
|
||||||
|
// The tines UUID with a valid NodeId is decoded.
|
||||||
|
let good: HashMap<Uuid, Vec<u8>> =
|
||||||
|
HashMap::from([(Uuid::from_u16(adv::SERVICE_UUID), node.to_vec())]);
|
||||||
|
assert_eq!(adv::decode_ad_node_id(&good, me, &allow), Some(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_ad_node_id_honours_allowlist() {
|
||||||
|
let me: NodeId = [7u8; 16];
|
||||||
|
let allowed: NodeId = [5u8; 16];
|
||||||
|
let denied: NodeId = [6u8; 16];
|
||||||
|
let service: HashMap<Uuid, Vec<u8>> =
|
||||||
|
HashMap::from([(Uuid::from_u16(adv::SERVICE_UUID), allowed.to_vec())]);
|
||||||
|
|
||||||
|
// Allowlist containing `allowed`: the advertiser is eligible.
|
||||||
|
let mut allow = BTreeSet::new();
|
||||||
|
allow.insert(allowed);
|
||||||
|
assert_eq!(adv::decode_ad_node_id(&service, me, &allow), Some(allowed));
|
||||||
|
|
||||||
|
// Allowlist not containing this advertiser: rejected.
|
||||||
|
let deny: BTreeSet<NodeId> = BTreeSet::from([denied]);
|
||||||
|
assert_eq!(adv::decode_ad_node_id(&service, me, &deny), None);
|
||||||
|
}
|
||||||
@@ -69,6 +69,34 @@ impl Frame {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Peek the [`NodeId`] a wire frame was sent by, without fully decoding it.
|
||||||
|
///
|
||||||
|
/// A [`Frame`] envelope is an externally-tagged postcard enum: one tag byte at
|
||||||
|
/// index `0`, then its fields in declaration order. `from` is the first field,
|
||||||
|
/// so it occupies the 16 bytes at `bytes[1..17]`. This reads those 16 bytes and
|
||||||
|
/// returns `None` for anything shorter than a full envelope (so a truncated or
|
||||||
|
/// non-frame payload reports no sender rather than panicking).
|
||||||
|
///
|
||||||
|
/// The leading byte is skipped on purpose: it is the externally-tagged variant
|
||||||
|
/// tag today and the reserved version-byte slot in 1.0, so reading `from` from
|
||||||
|
/// a fixed offset keeps this accessor correct when 1.0 prepends a version byte
|
||||||
|
/// ahead of the envelope.
|
||||||
|
///
|
||||||
|
/// `tines-bt` uses this to enforce that a frame's declared sender matches the
|
||||||
|
/// NodeId advertised by its transport connection (the from-check), without
|
||||||
|
/// importing the private `Frame` layout.
|
||||||
|
pub fn frame_sender(bytes: &[u8]) -> Option<NodeId> {
|
||||||
|
// The envelope is one tag byte then the `from` field; `NodeId` is 16 bytes.
|
||||||
|
const ENVELOPE_OVERHEAD: usize = 1;
|
||||||
|
const NODE_ID_BYTES: usize = core::mem::size_of::<NodeId>();
|
||||||
|
if bytes.len() < ENVELOPE_OVERHEAD + NODE_ID_BYTES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut from = [0u8; NODE_ID_BYTES];
|
||||||
|
from.copy_from_slice(&bytes[ENVELOPE_OVERHEAD..ENVELOPE_OVERHEAD + NODE_ID_BYTES]);
|
||||||
|
Some(from)
|
||||||
|
}
|
||||||
|
|
||||||
/// Errors produced while (de)serializing frames on the wire.
|
/// Errors produced while (de)serializing frames on the wire.
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub enum WireError {
|
pub enum WireError {
|
||||||
@@ -229,6 +257,52 @@ mod tests {
|
|||||||
assert_eq!(Frame::decode(&GOLDEN).unwrap(), expected);
|
assert_eq!(Frame::decode(&GOLDEN).unwrap(), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_sender_peeks_from_field() {
|
||||||
|
// The golden Summary encodes `from: [3;16]` starting at byte 1.
|
||||||
|
const GOLDEN: [u8; 200] = [
|
||||||
|
0u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8,
|
||||||
|
239u8, 253u8, 182u8, 245u8, 13u8, 128u8, 4u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8,
|
||||||
|
170u8, 170u8,
|
||||||
|
];
|
||||||
|
assert_eq!(frame_sender(&GOLDEN), Some([3u8; 16]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_sender_reads_from_fixed_offset_not_just_the_start() {
|
||||||
|
// The leading byte (the externally-tagged variant tag) is skipped, so a
|
||||||
|
// payload whose first byte is not the sender must still yield the `from`
|
||||||
|
// recorded at byte 1.
|
||||||
|
let bytes = {
|
||||||
|
let mut b = [0u8; 32];
|
||||||
|
b[0] = 0xFF; // tag/version slot
|
||||||
|
b[1..17].copy_from_slice(&[9u8; 16]); // from
|
||||||
|
b
|
||||||
|
};
|
||||||
|
assert_eq!(frame_sender(&bytes), Some([9u8; 16]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_sender_truncated_returns_none() {
|
||||||
|
assert_eq!(frame_sender(&[0u8; 0]), None);
|
||||||
|
assert_eq!(frame_sender(&[0u8; 16]), None); // tag + from missing
|
||||||
|
assert_eq!(frame_sender(&[0u8; 17]), Some([0u8; 16])); // exactly tag + from
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_garbage_is_error_not_panic() {
|
fn decode_garbage_is_error_not_panic() {
|
||||||
for len in [0usize, 1, 7, 64, 1500] {
|
for len in [0usize, 1, 7, 64, 1500] {
|
||||||
|
|||||||
Reference in New Issue
Block a user