hick-smoltcp 0.2.0

Runtime-agnostic mDNS / DNS-SD engine over smoltcp: the `mdns-proto` Sans-I/O core wired to smoltcp UDP. `no_std` + `alloc`.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
use core::net::{IpAddr, Ipv4Addr, SocketAddr};

use smoltcp::{
  iface::{Config, Interface, SocketSet},
  phy::{Device, DeviceCapabilities, Loopback, Medium, RxToken, TxToken},
  socket::udp,
  time::Instant as RawInstant,
  wire::{HardwareAddress, IpAddress, IpCidr, IpEndpoint, Ipv4Packet},
};

use super::DualStack;
use crate::udpio::{SendError, UdpIo};

/// A phy device that CAPTURES every egressed frame (Medium::Ip → raw IP packets)
/// so a test can inspect the on-wire IP header. Never delivers ingress.
#[derive(Default)]
struct CapturingDevice {
  sent: alloc::vec::Vec<alloc::vec::Vec<u8>>,
}
struct CapTxToken<'a> {
  sent: &'a mut alloc::vec::Vec<alloc::vec::Vec<u8>>,
}
impl TxToken for CapTxToken<'_> {
  fn consume<R, F: FnOnce(&mut [u8]) -> R>(self, len: usize, f: F) -> R {
    let mut buf = alloc::vec![0u8; len];
    let r = f(&mut buf);
    self.sent.push(buf);
    r
  }
}
struct CapRxToken;
impl RxToken for CapRxToken {
  fn consume<R, F: FnOnce(&[u8]) -> R>(self, _f: F) -> R {
    unreachable!("the capturing device never receives")
  }
}
impl Device for CapturingDevice {
  type RxToken<'a> = CapRxToken;
  type TxToken<'a> = CapTxToken<'a>;
  fn receive(&mut self, _t: RawInstant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
    None
  }
  fn transmit(&mut self, _t: RawInstant) -> Option<Self::TxToken<'_>> {
    Some(CapTxToken {
      sent: &mut self.sent,
    })
  }
  fn capabilities(&self) -> DeviceCapabilities {
    let mut caps = DeviceCapabilities::default();
    caps.medium = Medium::Ip;
    caps.max_transmission_unit = 1500;
    caps
  }
}

/// Build a port-5353 UDP socket over caller-owned buffers.
fn udp_socket<'a>(
  rx_meta: &'a mut [udp::PacketMetadata],
  rx_buf: &'a mut [u8],
  tx_meta: &'a mut [udp::PacketMetadata],
  tx_buf: &'a mut [u8],
) -> udp::Socket<'a> {
  udp::Socket::new(
    udp::PacketBuffer::new(rx_meta, rx_buf),
    udp::PacketBuffer::new(tx_meta, tx_buf),
  )
}

#[test]
fn dual_stack_routes_by_family_and_reports_absent() {
  // DualStack routes each datagram to the socket of its OWN family and
  // reports Unsupported for an absent family (so the engine confirms on the one
  // it has). The socket here is port-only (`bind(5353)`, the common wildcard
  // case): the absent family is gated by the `None` handle, not by the
  // family-ambiguous socket, so a wrong-family datagram is NEVER enqueued (which
  // would crash iface.poll).
  let mut rx_meta = [udp::PacketMetadata::EMPTY; 1];
  let mut rx_buf = [0u8; 256];
  let mut tx_meta = [udp::PacketMetadata::EMPTY; 1];
  let mut tx_buf = [0u8; 256];
  let socket = udp_socket(&mut rx_meta, &mut rx_buf, &mut tx_meta, &mut tx_buf);
  let mut storage: [_; 1] = Default::default();
  let mut sockets = SocketSet::new(&mut storage[..]);
  let h4 = sockets.add(socket);
  sockets.get_mut::<udp::Socket<'_>>(h4).bind(5353).unwrap();
  let mut io = DualStack::new(&mut sockets, Some(h4), None);
  // No v6 socket → a v6 destination is reported absent (Unsupported), NOT
  // enqueued on the v4 socket.
  let v6 = SocketAddr::new(
    IpAddr::V6(core::net::Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 0xfb)),
    5353,
  );
  assert_eq!(io.try_send(&[0u8; 8], v6), Err(SendError::Unsupported));
  // A v4 destination is routed to the present v4 socket (not Unsupported).
  let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(224, 0, 0, 251)), 5353);
  assert_ne!(io.try_send(&[0u8; 8], v4), Err(SendError::Unsupported));
}

#[test]
fn oversized_datagram_maps_to_too_large_not_busy() {
  // a datagram larger than the socket's TX payload capacity can never be
  // enqueued. smoltcp reports it with the SAME BufferFull as a momentarily-full
  // queue, so DualStack's send size-checks first and surfaces TooLarge
  // (permanent) — otherwise the engine treats it as transient Busy and retries it
  // forever instead of retiring the producer.
  let mut rx_meta = [udp::PacketMetadata::EMPTY; 1];
  let mut rx_buf = [0u8; 64];
  let mut tx_meta = [udp::PacketMetadata::EMPTY; 1];
  let mut tx_buf = [0u8; 64]; // a TX buffer far smaller than a legal mDNS packet
  let socket = udp_socket(&mut rx_meta, &mut rx_buf, &mut tx_meta, &mut tx_buf);
  let mut storage: [_; 1] = Default::default();
  let mut sockets = SocketSet::new(&mut storage[..]);
  let h4 = sockets.add(socket);
  sockets.get_mut::<udp::Socket<'_>>(h4).bind(5353).unwrap();
  assert!(
    sockets
      .get_mut::<udp::Socket<'_>>(h4)
      .payload_send_capacity()
      < 128
  );
  let mut io = DualStack::new(&mut sockets, Some(h4), None);
  let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(224, 0, 0, 251)), 5353);
  assert_eq!(
    io.try_send(&[0u8; 128], v4),
    Err(SendError::TooLarge),
    "a datagram exceeding the TX payload capacity must map to TooLarge, not Busy"
  );
  // A datagram within capacity is NOT mislabelled (queues, or transient Busy).
  assert_ne!(io.try_send(&[0u8; 8], v4), Err(SendError::TooLarge));
}

/// Round-trip a datagram through a smoltcp `Loopback` device to exercise the
/// [`DualStack`] `UdpIo`: `try_send` queues it, the interface egresses it back to
/// its own address, and `try_recv` yields it with correct source / destination
/// metadata.
#[test]
fn loopback_udpio_roundtrip() {
  const PORT: u16 = 5353;
  let own_v4 = Ipv4Addr::new(127, 0, 0, 1);
  let dst = SocketAddr::new(IpAddr::V4(own_v4), PORT);

  // Pure-L3 loopback (no Ethernet/ARP) so a self-addressed datagram loops.
  let mut device = Loopback::new(Medium::Ip);
  let config = Config::new(HardwareAddress::Ip);
  let mut iface = Interface::new(config, &mut device, RawInstant::ZERO);
  iface.update_ip_addrs(|addrs| {
    addrs
      .push(IpCidr::new(IpAddress::v4(127, 0, 0, 1), 8))
      .unwrap();
  });

  let mut rx_meta = [udp::PacketMetadata::EMPTY; 4];
  let mut rx_buf = [0u8; 1500];
  let mut tx_meta = [udp::PacketMetadata::EMPTY; 4];
  let mut tx_buf = [0u8; 1500];
  let socket = udp::Socket::new(
    udp::PacketBuffer::new(&mut rx_meta[..], &mut rx_buf[..]),
    udp::PacketBuffer::new(&mut tx_meta[..], &mut tx_buf[..]),
  );
  let mut sock_storage: [_; 2] = Default::default();
  let mut sockets = SocketSet::new(&mut sock_storage[..]);
  let handle = sockets.add(socket);
  sockets
    .get_mut::<udp::Socket<'_>>(handle)
    .bind(PORT)
    .unwrap();

  let payload = b"hick-mdns-loopback";
  // A fresh `DualStack` view per step (it borrows the SocketSet only for that
  // call), so `iface.poll` can take `&mut sockets` in between — mirroring how the
  // engine pumps one step at a time.
  DualStack::new(&mut sockets, Some(handle), None)
    .try_send(payload, dst)
    .expect("try_send should queue the datagram");

  // Drive egress -> loopback -> ingress.
  for _ in 0..8 {
    iface.poll(RawInstant::ZERO, &mut device, &mut sockets);
  }

  let mut buf = [0u8; 1500];
  let meta = DualStack::new(&mut sockets, Some(handle), None)
    .try_recv(&mut buf)
    .expect("the looped-back datagram should be received");

  assert_eq!(meta.len, payload.len());
  assert_eq!(&buf[..meta.len], payload);
  assert_eq!(meta.src, dst, "source is our own bound address:port");
  assert_eq!(
    meta.local,
    Some(IpAddr::V4(own_v4)),
    "local/destination address the datagram arrived on"
  );
  assert_eq!(
    meta.hop_limit, None,
    "smoltcp udp::Socket doesn't surface RX TTL"
  );
}

#[test]
fn egress_packets_carry_hop_limit_255() {
  // RFC 6762 §11: every outgoing mDNS packet MUST leave with IP TTL /
  // hop-limit 255, and conformant peers reject anything else at their on-link gate.
  // smoltcp defaults a UDP socket's hop-limit to 64, so the DualStack send path must
  // force 255 — assert it on the ACTUAL egressed IPv4 header, not just socket state.
  let mut device = CapturingDevice::default();
  let config = Config::new(HardwareAddress::Ip);
  let mut iface = Interface::new(config, &mut device, RawInstant::ZERO);
  iface.update_ip_addrs(|addrs| {
    addrs
      .push(IpCidr::new(IpAddress::v4(192, 168, 1, 10), 24))
      .unwrap();
  });

  let mut rx_meta = [udp::PacketMetadata::EMPTY; 1];
  let mut rx_buf = [0u8; 256];
  let mut tx_meta = [udp::PacketMetadata::EMPTY; 1];
  let mut tx_buf = [0u8; 256];
  let socket = udp_socket(&mut rx_meta, &mut rx_buf, &mut tx_meta, &mut tx_buf);
  let mut storage: [_; 1] = Default::default();
  let mut sockets = SocketSet::new(&mut storage[..]);
  let h4 = sockets.add(socket);
  sockets.get_mut::<udp::Socket<'_>>(h4).bind(5353).unwrap();

  // Send to the IPv4 mDNS group through DualStack — the engine's real destination.
  let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(224, 0, 0, 251)), 5353);
  DualStack::new(&mut sockets, Some(h4), None)
    .try_send(b"hick-mdns", dst)
    .expect("try_send should queue the datagram");

  // Drive egress into the capturing device.
  for _ in 0..4 {
    iface.poll(RawInstant::ZERO, &mut device, &mut sockets);
  }

  assert!(
    !device.sent.is_empty(),
    "the datagram must have egressed to the device"
  );
  let frame = &device.sent[0];
  let ip = Ipv4Packet::new_checked(&frame[..]).expect("a valid IPv4 packet egressed");
  assert_eq!(
    ip.hop_limit(),
    255,
    "RFC 6762 §11: every outgoing mDNS packet must leave with IP TTL 255, not the \
       smoltcp default 64"
  );
}

#[test]
fn oversized_received_datagram_yields_a_drop_marker_not_a_loop() {
  // a received datagram larger than the engine's scratch is dropped by smoltcp
  // (RecvError::Truncated). recv_from must surface a zero-length MARKER so the engine
  // counts it against MAX_RX_PER_PUMP, instead of looping to find the next fitting
  // datagram (which would drain the whole oversized backlog in one uncapped pass).
  const PORT: u16 = 5353;
  let own = Ipv4Addr::new(127, 0, 0, 1);
  let dst = SocketAddr::new(IpAddr::V4(own), PORT);
  let mut device = Loopback::new(Medium::Ip);
  let config = Config::new(HardwareAddress::Ip);
  let mut iface = Interface::new(config, &mut device, RawInstant::ZERO);
  iface.update_ip_addrs(|addrs| {
    addrs
      .push(IpCidr::new(IpAddress::v4(127, 0, 0, 1), 8))
      .unwrap();
  });
  let mut rx_meta = [udp::PacketMetadata::EMPTY; 4];
  let mut rx_buf = [0u8; 1500];
  let mut tx_meta = [udp::PacketMetadata::EMPTY; 4];
  let mut tx_buf = [0u8; 1500];
  let socket = udp::Socket::new(
    udp::PacketBuffer::new(&mut rx_meta[..], &mut rx_buf[..]),
    udp::PacketBuffer::new(&mut tx_meta[..], &mut tx_buf[..]),
  );
  let mut sock_storage: [_; 2] = Default::default();
  let mut sockets = SocketSet::new(&mut sock_storage[..]);
  let handle = sockets.add(socket);
  sockets
    .get_mut::<udp::Socket<'_>>(handle)
    .bind(PORT)
    .unwrap();

  // Send a 100-byte datagram to ourselves.
  DualStack::new(&mut sockets, Some(handle), None)
    .try_send(&[0xABu8; 100], dst)
    .expect("try_send should queue the datagram");
  for _ in 0..8 {
    iface.poll(RawInstant::ZERO, &mut device, &mut sockets);
  }

  // Receive with a buffer SMALLER than the datagram → smoltcp truncates/drops it.
  let mut small = [0u8; 50];
  let meta = DualStack::new(&mut sockets, Some(handle), None)
    .try_recv(&mut small)
    .expect("a drop marker (Some), not None");
  assert_eq!(
    meta.len, 0,
    "an oversized datagram must surface as a zero-length drop marker"
  );
  // The oversized datagram was consumed (no loop, no leftover).
  assert!(
    DualStack::new(&mut sockets, Some(handle), None)
      .try_recv(&mut small)
      .is_none(),
    "the dropped datagram must have been consumed"
  );
}

#[test]
fn try_recv_round_robins_handles_so_one_backlog_cannot_starve_the_other() {
  // with the engine's per-pump RX cap, a strict first-family drain lets a
  // sustained backlog on one socket consume the whole cap every pump and starve the
  // other. `try_recv` must alternate the two handles. Two distinct loopback addresses
  // stand in for the v4/v6 handles (the round-robin alternates handles regardless of
  // family), avoiding a v6 loopback setup.
  let addr_a = Ipv4Addr::new(127, 0, 0, 1);
  let addr_b = Ipv4Addr::new(127, 0, 0, 2);
  let mut device = Loopback::new(Medium::Ip);
  let config = Config::new(HardwareAddress::Ip);
  let mut iface = Interface::new(config, &mut device, RawInstant::ZERO);
  iface.update_ip_addrs(|addrs| {
    addrs
      .push(IpCidr::new(IpAddress::v4(127, 0, 0, 1), 8))
      .unwrap();
    addrs
      .push(IpCidr::new(IpAddress::v4(127, 0, 0, 2), 8))
      .unwrap();
  });
  let mut ra = [udp::PacketMetadata::EMPTY; 8];
  let mut rab = [0u8; 2048];
  let mut ta = [udp::PacketMetadata::EMPTY; 8];
  let mut tab = [0u8; 2048];
  let mut rb = [udp::PacketMetadata::EMPTY; 8];
  let mut rbb = [0u8; 2048];
  let mut tb = [udp::PacketMetadata::EMPTY; 8];
  let mut tbb = [0u8; 2048];
  let sa = udp::Socket::new(
    udp::PacketBuffer::new(&mut ra[..], &mut rab[..]),
    udp::PacketBuffer::new(&mut ta[..], &mut tab[..]),
  );
  let sb = udp::Socket::new(
    udp::PacketBuffer::new(&mut rb[..], &mut rbb[..]),
    udp::PacketBuffer::new(&mut tb[..], &mut tbb[..]),
  );
  let mut storage: [_; 4] = Default::default();
  let mut sockets = SocketSet::new(&mut storage[..]);
  let ha = sockets.add(sa);
  let hb = sockets.add(sb);
  sockets
    .get_mut::<udp::Socket<'_>>(ha)
    .bind(IpEndpoint::new(IpAddress::v4(127, 0, 0, 1), 5353))
    .unwrap();
  sockets
    .get_mut::<udp::Socket<'_>>(hb)
    .bind(IpEndpoint::new(IpAddress::v4(127, 0, 0, 2), 5353))
    .unwrap();

  // Backlog: 4 datagrams to A, 2 to B. Sent directly (DualStack routing would send
  // both v4 destinations to the v4 handle).
  for _ in 0..4 {
    sockets
      .get_mut::<udp::Socket<'_>>(ha)
      .send_slice(b"a", IpEndpoint::new(IpAddress::v4(127, 0, 0, 1), 5353))
      .unwrap();
  }
  for _ in 0..2 {
    sockets
      .get_mut::<udp::Socket<'_>>(hb)
      .send_slice(b"b", IpEndpoint::new(IpAddress::v4(127, 0, 0, 2), 5353))
      .unwrap();
  }
  for _ in 0..16 {
    iface.poll(RawInstant::ZERO, &mut device, &mut sockets);
  }

  // Drain via the round-robin `try_recv` and record which handle each came from.
  let mut io = DualStack::new(&mut sockets, Some(ha), Some(hb));
  let mut buf = [0u8; 64];
  let mut from_a = alloc::vec::Vec::new();
  while let Some(meta) = io.try_recv(&mut buf) {
    if meta.len > 0 {
      from_a.push(meta.src.ip() == IpAddr::V4(addr_a));
    }
  }
  let _ = addr_b;
  assert_eq!(
    from_a.iter().filter(|&&a| a).count(),
    4,
    "all 4 datagrams on handle A must arrive; order = {from_a:?}"
  );
  assert_eq!(
    from_a.iter().filter(|&&a| !a).count(),
    2,
    "both datagrams on handle B must arrive; order = {from_a:?}"
  );
  let first_b = from_a
    .iter()
    .position(|&a| !a)
    .expect("a handle-B datagram");
  assert!(
    first_b < 4,
    "handle B must interleave, not be starved behind A's backlog; order = {from_a:?}"
  );
}