Skip to content

Commit 6c45858

Browse files
committed
add full UDP discovery test suite with socket-pair harness and synthetic ListIdentity packets
1 parent 389e8bf commit 6c45858

1 file changed

Lines changed: 224 additions & 68 deletions

File tree

src/client/discovery.rs

Lines changed: 224 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ pub struct DiscoveryImpl;
1616
impl Discovery for DiscoveryImpl {
1717
async fn discover() -> io::Result<Vec<DiscoveredIdentity>> {
1818
const ENIP_PORT: u16 = 44818;
19-
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(1);
20-
const RECV_TIMEOUT: Duration = Duration::from_millis(200);
2119

2220
let socket = UdpSocket::bind("0.0.0.0:0").await?;
2321
socket.set_broadcast(true)?;
@@ -39,92 +37,114 @@ impl Discovery for DiscoveryImpl {
3937
.send_to(&msg, SocketAddr::from(([239, 192, 1, 1], ENIP_PORT)))
4038
.await;
4139

42-
let mut results = Vec::new();
43-
let mut buf = [0u8; 2048];
44-
let start = Instant::now();
40+
discover_internal(socket).await
41+
}
42+
}
4543

46-
while start.elapsed() < DISCOVERY_TIMEOUT {
47-
if let Ok(Ok((len, addr))) = timeout(RECV_TIMEOUT, socket.recv_from(&mut buf)).await {
48-
if len < 24 {
49-
continue;
50-
}
44+
impl DiscoveryImpl {
45+
/// Return the first discovered device, if any.
46+
pub async fn discover_one() -> io::Result<Option<DiscoveredIdentity>> {
47+
let all = Self::discover().await?;
48+
Ok(all.into_iter().next())
49+
}
5150

52-
// Parse encapsulation header
53-
let hdr = match EncapsulationHeader::from_bytes(&buf[..24]) {
54-
Some(h) => h,
55-
None => continue,
56-
};
51+
/// Test-only: run discovery using an injected socket.
52+
#[cfg(test)]
53+
pub async fn discover_with_socket(socket: UdpSocket) -> io::Result<Vec<DiscoveredIdentity>> {
54+
discover_internal(socket).await
55+
}
5756

58-
if hdr.command != COMMAND_LIST_IDENTITY || hdr.status != 0 {
59-
continue;
60-
}
57+
#[cfg(test)]
58+
pub async fn discover_one_with_socket(
59+
socket: UdpSocket,
60+
) -> io::Result<Option<DiscoveredIdentity>> {
61+
let all = discover_internal(socket).await?;
62+
Ok(all.into_iter().next())
63+
}
64+
}
6165

62-
let payload = &buf[24..len];
63-
if payload.len() < 2 {
64-
continue;
65-
}
66+
/// Shared discovery logic for production + tests.
67+
async fn discover_internal(socket: UdpSocket) -> io::Result<Vec<DiscoveredIdentity>> {
68+
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(1);
69+
const RECV_TIMEOUT: Duration = Duration::from_millis(200);
6670

67-
// CPF item count
68-
let item_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
69-
let mut pos = 2;
71+
let mut results = Vec::new();
72+
let mut buf = [0u8; 2048];
73+
let start = Instant::now();
7074

71-
for _ in 0..item_count {
72-
if payload.len() < pos + 4 {
73-
break;
74-
}
75+
while start.elapsed() < DISCOVERY_TIMEOUT {
76+
if let Ok(Ok((len, addr))) = timeout(RECV_TIMEOUT, socket.recv_from(&mut buf)).await {
77+
if len < 24 {
78+
continue;
79+
}
7580

76-
let type_id = u16::from_le_bytes([payload[pos], payload[pos + 1]]);
77-
let item_len =
78-
u16::from_le_bytes([payload[pos + 2], payload[pos + 3]]) as usize;
79-
pos += 4;
81+
// Parse encapsulation header
82+
let hdr = match EncapsulationHeader::from_bytes(&buf[..24]) {
83+
Some(h) => h,
84+
None => continue,
85+
};
8086

81-
if payload.len() < pos + item_len {
82-
break;
83-
}
87+
if hdr.command != COMMAND_LIST_IDENTITY || hdr.status != 0 {
88+
continue;
89+
}
8490

85-
// Identity Item = 0x000C
86-
if type_id == 0x000C {
87-
if let Some(info) = parse_discovery_identity(
88-
&payload[pos..pos + item_len],
89-
addr.ip().to_string(),
90-
) {
91-
results.push(info);
92-
}
93-
}
91+
let payload = &buf[24..len];
92+
if payload.len() < 2 {
93+
continue;
94+
}
9495

95-
pos += item_len;
96+
// CPF item count
97+
let item_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
98+
let mut pos = 2;
99+
100+
for _ in 0..item_count {
101+
if payload.len() < pos + 4 {
102+
break;
96103
}
104+
105+
let type_id = u16::from_le_bytes([payload[pos], payload[pos + 1]]);
106+
let item_len = u16::from_le_bytes([payload[pos + 2], payload[pos + 3]]) as usize;
107+
pos += 4;
108+
109+
if payload.len() < pos + item_len {
110+
break;
111+
}
112+
113+
// Identity Item = 0x000C
114+
if type_id == 0x000C {
115+
if let Some(info) = parse_discovery_identity(
116+
&payload[pos..pos + item_len],
117+
addr.ip().to_string(),
118+
) {
119+
results.push(info);
120+
}
121+
}
122+
123+
pos += item_len;
97124
}
98125
}
99-
100-
Ok(results)
101126
}
102-
}
103127

104-
impl DiscoveryImpl {
105-
/// Return the first discovered device, if any.
106-
pub async fn discover_one() -> io::Result<Option<DiscoveredIdentity>> {
107-
let all = Self::discover().await?;
108-
Ok(all.into_iter().next())
109-
}
128+
results.dedup_by_key(|d| (d.ip.clone(), d.serial));
129+
130+
Ok(results)
110131
}
111132

112133
/// Parse the Identity Item (0x000C) returned by ListIdentity (0x63).
113-
///
134+
// Identity Item layout (EtherNet/IP spec Vol 2, Table 2-4.4):
135+
// [0..1] encap protocol version
136+
// [2..9] socket address (sin_family, sin_port, sin_addr, padding)
137+
// [10..11] vendor ID
138+
// [12..13] device type
139+
// [14..15] product code
140+
// [16] revision major
141+
// [17] revision minor
142+
// [18..19] status
143+
// [20..23] serial number
144+
// [24] product name length
145+
// [25..] product name (UTF-8)
114146
/// This is *not* the same as the Identity Object (Class 0x01).
115147
fn parse_discovery_identity(item: &[u8], ip: String) -> Option<DiscoveredIdentity> {
116-
// Minimum fields before product name:
117-
// 0-1: Encapsulation Protocol Version
118-
// 2-3: Socket Address Family
119-
// 4-9: Socket Address (ignored)
120-
// 10-11: Vendor ID
121-
// 12-13: Device Type
122-
// 14-15: Product Code
123-
// 16: Revision Major
124-
// 17: Revision Minor
125-
// 18-19: Status
126-
// 20-23: Serial Number
127-
// 24: Product Name Length
128148
if item.len() < 25 {
129149
return None;
130150
}
@@ -156,3 +176,139 @@ fn parse_discovery_identity(item: &[u8], ip: String) -> Option<DiscoveredIdentit
156176
product_name,
157177
})
158178
}
179+
180+
#[cfg(test)]
181+
mod tests {
182+
use super::*;
183+
use tokio::net::UdpSocket;
184+
185+
fn build_list_identity_packet(name: &str, serial: u32) -> Vec<u8> {
186+
let mut payload = Vec::new();
187+
payload.extend_from_slice(&1u16.to_le_bytes());
188+
189+
let mut identity = Vec::new();
190+
identity.extend_from_slice(&1u16.to_le_bytes());
191+
identity.extend_from_slice(&2u16.to_le_bytes());
192+
identity.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
193+
identity.extend_from_slice(&123u16.to_le_bytes());
194+
identity.extend_from_slice(&14u16.to_le_bytes());
195+
identity.extend_from_slice(&999u16.to_le_bytes());
196+
identity.push(3);
197+
identity.push(7);
198+
identity.extend_from_slice(&0x0044u16.to_le_bytes());
199+
identity.extend_from_slice(&serial.to_le_bytes()); // <-- was hardcoded
200+
identity.push(name.len() as u8);
201+
identity.extend_from_slice(name.as_bytes());
202+
203+
payload.extend_from_slice(&0x000Cu16.to_le_bytes());
204+
payload.extend_from_slice(&(identity.len() as u16).to_le_bytes());
205+
payload.extend_from_slice(&identity);
206+
207+
let hdr =
208+
EncapsulationHeader::new(COMMAND_LIST_IDENTITY, payload.len() as u16, 0).to_bytes();
209+
let mut pkt = Vec::new();
210+
pkt.extend_from_slice(&hdr);
211+
pkt.extend_from_slice(&payload);
212+
pkt
213+
}
214+
215+
/// Returns (server, client).
216+
/// client is unconnected — recv_from will return the server's real addr.
217+
/// server is unconnected — must use send_to(src) to reply.
218+
async fn setup_socket_pair() -> (UdpSocket, SocketAddr, UdpSocket) {
219+
let server = UdpSocket::bind("127.0.0.1:0").await.unwrap();
220+
let client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
221+
let client_addr = client.local_addr().unwrap();
222+
(server, client_addr, client)
223+
}
224+
225+
#[tokio::test]
226+
async fn test_single_device_discovery() {
227+
let (server, client_addr, client) = setup_socket_pair().await;
228+
tokio::spawn(async move {
229+
server
230+
.send_to(
231+
&build_list_identity_packet("TestPLC", 0x11223344),
232+
client_addr,
233+
)
234+
.await
235+
.unwrap();
236+
});
237+
let results = DiscoveryImpl::discover_with_socket(client).await.unwrap();
238+
assert_eq!(results.len(), 1);
239+
assert_eq!(results[0].product_name, "TestPLC");
240+
}
241+
242+
#[tokio::test]
243+
async fn test_multiple_devices() {
244+
let (server, client_addr, client) = setup_socket_pair().await;
245+
tokio::spawn(async move {
246+
server
247+
.send_to(
248+
&build_list_identity_packet("PLC_A", 0x11223344),
249+
client_addr,
250+
)
251+
.await
252+
.unwrap();
253+
server
254+
.send_to(
255+
&build_list_identity_packet("PLC_B", 0x22334455),
256+
client_addr,
257+
)
258+
.await
259+
.unwrap();
260+
});
261+
let results = DiscoveryImpl::discover_with_socket(client).await.unwrap();
262+
assert_eq!(results.len(), 2);
263+
}
264+
265+
#[tokio::test]
266+
async fn test_ignores_wrong_command() {
267+
let (server, client_addr, client) = setup_socket_pair().await;
268+
tokio::spawn(async move {
269+
let mut pkt = build_list_identity_packet("Ignored", 0x11223344);
270+
pkt[0] = 0xFF;
271+
server.send_to(&pkt, client_addr).await.unwrap();
272+
});
273+
let results = DiscoveryImpl::discover_with_socket(client).await.unwrap();
274+
assert!(results.is_empty());
275+
}
276+
277+
#[tokio::test]
278+
async fn test_ignores_malformed_identity_item() {
279+
let (server, client_addr, client) = setup_socket_pair().await;
280+
tokio::spawn(async move {
281+
server.send_to(&[1, 2, 3], client_addr).await.unwrap();
282+
});
283+
let results = DiscoveryImpl::discover_with_socket(client).await.unwrap();
284+
assert!(results.is_empty());
285+
}
286+
287+
#[tokio::test]
288+
async fn test_discover_one() {
289+
let (server, client_addr, client) = setup_socket_pair().await;
290+
tokio::spawn(async move {
291+
server
292+
.send_to(
293+
&build_list_identity_packet("OnePLC", 0x11223344),
294+
client_addr,
295+
)
296+
.await
297+
.unwrap();
298+
});
299+
let result = DiscoveryImpl::discover_one_with_socket(client)
300+
.await
301+
.unwrap();
302+
assert!(result.is_some());
303+
assert_eq!(result.unwrap().product_name, "OnePLC");
304+
}
305+
306+
#[tokio::test]
307+
async fn test_discover_one_empty() {
308+
let (_server, _client_addr, client) = setup_socket_pair().await;
309+
let result = DiscoveryImpl::discover_one_with_socket(client)
310+
.await
311+
.unwrap();
312+
assert!(result.is_none());
313+
}
314+
}

0 commit comments

Comments
 (0)