#![allow(clippy::expect_used)]
use std::{future::Future, net::SocketAddr, sync::Arc, time::Duration};
use async_trait::async_trait;
use iec104::{
asdu::Asdu,
client::{Client, ClientCallback},
config::{ClientConfig, ProtocolConfig, ServerConfig},
cot::Cot,
rtu_server::{
PointAddress, PointValue, RejectAllCommands, RtuInitialPoint, RtuSystemHandlers,
command_handler_from_fn,
},
server::{ConnectionId, Server, ServerCallback},
types::{
MMeNa1,
information_elements::{SelectExecute, Spi},
quality_descriptors::Qds,
},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
sync::mpsc,
time::timeout,
};
use super::{WAIT, drain_until_m_ei, m_sp_off};
#[tokio::test]
async fn send_command_sp_uses_activation_cot_and_configured_originator() {
let ca = 21_u16;
let ioa = 7_u32;
let initial =
vec![RtuInitialPoint::new(PointAddress::new(ca, ioa), PointValue::MSpNa1(m_sp_off()))];
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<(Cot, u8)>();
let handler = command_handler_from_fn(move |ctx| {
let _ = cmd_tx.send((ctx.asdu.cot, ctx.asdu.originator_address));
iec104::rtu_server::CommandHandling {
negative: false,
reply_type_id: ctx.asdu.type_id,
reply_information_objects: ctx.asdu.information_objects.clone(),
apply_updates: Vec::new(),
}
});
let (rtu, listen) = iec104::rtu_server::start_rtu_server_ephemeral(
ServerConfig::default(),
initial,
handler,
RtuSystemHandlers::default(),
)
.await
.expect("start_rtu_server_ephemeral");
let protocol = ProtocolConfig {
originator_address: 42,
t1: Duration::from_secs(8),
t3: Duration::from_secs(8),
..ProtocolConfig::default()
};
let cfg =
ClientConfig { address: listen.ip().to_string(), port: listen.port(), protocol, tls: None };
let (asdu_tx, mut asdu_rx) = mpsc::unbounded_channel();
let mut client = Client::new(cfg, super::AsduCollector { tx: asdu_tx });
client.connect().await.expect("connect");
client.start_receiving().await.expect("start_receiving");
drain_until_m_ei(&mut asdu_rx).await;
client
.send_command_sp(ca, ioa, Spi::On, None, Some(SelectExecute::Execute), None)
.await
.expect("send_command_sp");
let (cot, oa) = timeout(WAIT, cmd_rx.recv()).await.expect("timeout").expect("channel closed");
assert_eq!(cot, Cot::Activation, "commands must use Cot::Activation per IEC 101 Table 14");
assert_eq!(oa, 42, "originator_address must come from ProtocolConfig, not hard-coded 0");
drop(rtu);
}
const START_DT_ACT_BYTES: [u8; 6] = [0x68, 0x04, 0x07, 0x00, 0x00, 0x00];
const START_DT_CON_BYTES: [u8; 6] = [0x68, 0x04, 0x0B, 0x00, 0x00, 0x00];
const TEST_FR_ACT_BYTES: [u8; 6] = [0x68, 0x04, 0x43, 0x00, 0x00, 0x00];
const TEST_FR_CON_BYTES: [u8; 6] = [0x68, 0x04, 0x83, 0x00, 0x00, 0x00];
struct NoopCallback;
#[async_trait]
impl ServerCallback for NoopCallback {
async fn on_new_objects(&self, _asdu: Asdu, _id: ConnectionId, _addr: SocketAddr) {}
}
async fn start_bare_server() -> (Server, SocketAddr) {
let cfg = ServerConfig {
address: "127.0.0.1".to_owned(),
port: 0,
protocol: ProtocolConfig::default(),
tls: None,
};
Server::start_with_listen_addr(cfg, NoopCallback).await.expect("start server")
}
async fn read_apdu(stream: &mut TcpStream) -> Vec<u8> {
let mut header = [0_u8; 2];
timeout(WAIT, stream.read_exact(&mut header))
.await
.expect("read header timeout")
.expect("read header");
assert_eq!(header[0], 0x68, "expected APDU start byte");
let len = header[1] as usize;
let mut rest = vec![0_u8; len];
timeout(WAIT, stream.read_exact(&mut rest))
.await
.expect("read body timeout")
.expect("read body");
let mut full = Vec::with_capacity(2 + len);
full.extend_from_slice(&header);
full.extend_from_slice(&rest);
full
}
#[tokio::test]
async fn server_answers_test_fr_act_before_start_dt() {
let (_server, addr) = start_bare_server().await;
let mut sock = TcpStream::connect(addr).await.expect("tcp connect");
sock.write_all(&TEST_FR_ACT_BYTES).await.expect("write test_fr_act");
let reply = read_apdu(&mut sock).await;
assert_eq!(reply, TEST_FR_CON_BYTES, "server must reply with TestFR-CON in WaitingStartDT");
sock.write_all(&START_DT_ACT_BYTES).await.expect("write start_dt_act");
let reply = read_apdu(&mut sock).await;
assert_eq!(reply, START_DT_CON_BYTES, "StartDT-ACT must still confirm after a TestFR exchange");
}
#[tokio::test]
async fn server_closes_on_invalid_frame_in_waiting_start_dt() {
let (_server, addr) = start_bare_server().await;
let mut sock = TcpStream::connect(addr).await.expect("tcp connect");
let stop_dt_act = [0x68, 0x04, 0x13, 0x00, 0x00, 0x00];
sock.write_all(&stop_dt_act).await.expect("write stop_dt_act");
let mut buf = [0_u8; 1];
let res = timeout(Duration::from_secs(2), sock.read(&mut buf)).await.expect("io timeout");
match res {
Ok(0) => {} Ok(n) => panic!("expected server to close, got {n} bytes"),
Err(e) => panic!("unexpected io error: {e}"),
}
}
async fn fake_server<F, Fut>(handler: F) -> SocketAddr
where
F: FnOnce(TcpStream) -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local_addr");
tokio::spawn(async move {
if let Ok((sock, _)) = listener.accept().await {
handler(sock).await;
}
});
addr
}
struct DiscardingCallback;
#[async_trait]
impl ClientCallback for DiscardingCallback {
async fn on_new_objects(&self, _asdu: Asdu) {}
}
#[tokio::test]
async fn client_rejects_non_u_frame_as_start_dt_response() {
let addr = fake_server(|mut sock| async move {
let mut buf = [0_u8; 6];
let _ = sock.read_exact(&mut buf).await;
let s_frame = [0x68, 0x04, 0x01, 0x00, 0x00, 0x00];
let _ = sock.write_all(&s_frame).await;
tokio::time::sleep(Duration::from_millis(500)).await;
})
.await;
let protocol = ProtocolConfig { t1: Duration::from_secs(2), ..ProtocolConfig::default() };
let cfg =
ClientConfig { address: addr.ip().to_string(), port: addr.port(), protocol, tls: None };
let mut client = Client::new(cfg, DiscardingCallback);
client.connect().await.expect("connect");
client.start_receiving().await.expect("queue start command");
tokio::time::sleep(Duration::from_millis(300)).await;
let res = client.send_command_sp(1, 1, Spi::On, None, Some(SelectExecute::Execute), None).await;
assert!(res.is_err(), "client must not reach Started after a non-U StartDT response");
}
#[tokio::test]
async fn in_session_start_dt_act_closes_the_connection() {
let ca = 31_u16;
let initial = vec![RtuInitialPoint::new(
PointAddress::new(ca, 1),
PointValue::MMeNa1(MMeNa1 { nva: 0, qds: Qds::default() }),
)];
let (rtu, listen) = iec104::rtu_server::start_rtu_server_ephemeral(
ServerConfig::default(),
initial,
Arc::new(RejectAllCommands),
RtuSystemHandlers::default(),
)
.await
.expect("start_rtu_server_ephemeral");
let mut sock = TcpStream::connect(listen).await.expect("tcp connect");
sock.write_all(&START_DT_ACT_BYTES).await.expect("write start_dt_act");
let _ = read_apdu(&mut sock).await;
sock.write_all(&START_DT_ACT_BYTES).await.expect("write 2nd start_dt_act");
let mut buf = [0_u8; 256];
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
let closed = loop {
if tokio::time::Instant::now() >= deadline {
break false;
}
match timeout(Duration::from_millis(500), sock.read(&mut buf)).await {
Ok(Ok(0)) => break true,
Ok(Ok(_)) => continue,
Ok(Err(_)) => break true,
Err(_) => continue,
}
};
assert!(closed, "server must close connection on stray StartDT-ACT");
drop(rtu);
}
#[tokio::test]
async fn server_start_rejects_invalid_k_w_ratio() {
let cfg = ServerConfig {
address: "127.0.0.1".to_owned(),
port: 0,
protocol: ProtocolConfig { k: 12, w: 9, ..ProtocolConfig::default() },
tls: None,
};
let res = Server::start_with_listen_addr(cfg, NoopCallback).await;
assert!(res.is_err(), "Server::start must refuse w > 2k/3");
}
#[tokio::test]
async fn client_connect_rejects_invalid_k_w_ratio() {
let cfg = ClientConfig {
address: "127.0.0.1".to_owned(),
port: 1,
protocol: ProtocolConfig { k: 12, w: 9, ..ProtocolConfig::default() },
tls: None,
};
let mut client = Client::new(cfg, DiscardingCallback);
let res = client.connect().await;
assert!(res.is_err(), "Client::connect must refuse w > 2k/3");
}