From 97ef3c0743bb088e355e413d6b34c23e9e8a6064 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Mon, 4 May 2026 13:30:08 -0700 Subject: [PATCH] add class 1/2/3 event override fields to ApplicationIin Allows applications that buffer events in external storage upstream of the DNP3 stack to assert the CLASS_n_EVENTS IIN bits on outgoing responses even when the in-memory event buffer is empty. The new fields are OR'd with the buffer-derived bits in get_response_iin, so false is a no-op and true asserts the bit regardless of in-memory buffer state. The auto-generated all-fields ApplicationIin constructor in the C++/C#/ Java bindings goes from 4-arg to 7-arg; the no-arg constructor is unchanged (new fields default to false in the schema initializer). Closes #419 --- CHANGELOG.md | 2 + dnp3/src/app/header.rs | 12 +++ .../outstation/tests/harness/application.rs | 10 +- dnp3/src/outstation/tests/iin.rs | 99 +++++++++++++++++++ dnp3/src/outstation/tests/unsolicited.rs | 25 +++++ dnp3/src/outstation/traits.rs | 16 +++ ffi/dnp3-ffi/src/outstation/adapters.rs | 3 + ffi/dnp3-schema/src/outstation.rs | 21 ++++ 8 files changed, 187 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6c7cab6..9e5697a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ### 1.7.0-RC2 ### +* :star: Allow application to indicate pending class events held in external storage via new `class_1_events` / `class_2_events` / `class_3_events` fields on `ApplicationIin`. Useful for outstations that buffer events upstream of the DNP3 stack and need the master to keep polling while they drain. See [#419](https://github.com/stepfunc/dnp3/issues/419). +* :warning: **Breaking change (FFI all-fields constructor):** `ApplicationIin` gains three new fields, so the auto-generated all-fields constructor in C++/C#/Java goes from 4-arg to 7-arg. The no-arg `ApplicationIin()` constructor is unchanged (the new fields default to `false`). Rust callers using `..Default::default()` are unaffected; positional struct literals must be updated. * :shield: Update `rustls-webpki` to 0.103.12 to resolve [RUSTSEC-2026-0098](https://rustsec.org/advisories/RUSTSEC-2026-0098) and [RUSTSEC-2026-0099](https://rustsec.org/advisories/RUSTSEC-2026-0099), both concerning incorrect acceptance of X.509 name constraints. Exposure is limited to TLS configurations using `CertificateMode::AuthorityBased`; `SelfSigned` mode bypasses the affected code path. * :bell: **This update only affects the prebuilt binary distributions of the bindings (C/C++, .NET, Java).** Rust consumers of the `dnp3` crate pick up the patched `rustls-webpki` automatically on rebuild. diff --git a/dnp3/src/app/header.rs b/dnp3/src/app/header.rs index f8040c9b8..9eba1cf3b 100644 --- a/dnp3/src/app/header.rs +++ b/dnp3/src/app/header.rs @@ -403,6 +403,18 @@ impl BitOr for Iin { self |= Iin2::CONFIG_CORRUPT; } + if rhs.class_1_events { + self |= Iin1::CLASS_1_EVENTS; + } + + if rhs.class_2_events { + self |= Iin1::CLASS_2_EVENTS; + } + + if rhs.class_3_events { + self |= Iin1::CLASS_3_EVENTS; + } + self } } diff --git a/dnp3/src/outstation/tests/harness/application.rs b/dnp3/src/outstation/tests/harness/application.rs index b6d7f002e..44f882323 100644 --- a/dnp3/src/outstation/tests/harness/application.rs +++ b/dnp3/src/outstation/tests/harness/application.rs @@ -3,7 +3,9 @@ use std::sync::{Arc, Mutex}; use crate::app::{MaybeAsync, Timestamp}; use crate::outstation::database::DatabaseHandle; use crate::outstation::tests::harness::{Event, EventSender}; -use crate::outstation::traits::{OutstationApplication, RequestError, RestartDelay}; +use crate::outstation::traits::{ + ApplicationIin, OutstationApplication, RequestError, RestartDelay, +}; use crate::outstation::{BufferState, FreezeIndices, FreezeType}; pub(crate) struct MockOutstationApplication { @@ -14,6 +16,7 @@ pub(crate) struct MockOutstationApplication { pub(crate) struct ApplicationData { pub(crate) processing_delay: u16, pub(crate) restart_delay: Option, + pub(crate) application_iin: ApplicationIin, } impl ApplicationData { @@ -21,6 +24,7 @@ impl ApplicationData { Self { processing_delay: 0, restart_delay: None, + application_iin: ApplicationIin::default(), } } } @@ -39,6 +43,10 @@ impl OutstationApplication for MockOutstationApplication { self.data.lock().unwrap().processing_delay } + fn get_application_iin(&self) -> ApplicationIin { + self.data.lock().unwrap().application_iin + } + fn write_absolute_time(&mut self, time: Timestamp) -> Result<(), RequestError> { self.events.send(Event::WriteAbsoluteTime(time)); Ok(()) diff --git a/dnp3/src/outstation/tests/iin.rs b/dnp3/src/outstation/tests/iin.rs index 4fb9770a5..502c8235a 100644 --- a/dnp3/src/outstation/tests/iin.rs +++ b/dnp3/src/outstation/tests/iin.rs @@ -1,6 +1,7 @@ use crate::app::measurement::{BinaryInput, Flags, Time}; use crate::app::Timestamp; use crate::outstation::database::{Add, BinaryInputConfig, EventClass, Update, UpdateOptions}; +use crate::outstation::traits::ApplicationIin; use super::harness::*; @@ -97,3 +98,101 @@ async fn buffer_overflow() { .test_request_response(READ_CLASS_123, EMPTY_RESPONSE) .await; } + +#[tokio::test] +async fn application_iin_forces_class_event_bits_with_empty_buffer() { + let mut harness = new_harness(get_default_config()); + + harness.application_data.lock().unwrap().application_iin = ApplicationIin { + class_1_events: true, + class_2_events: true, + class_3_events: true, + ..ApplicationIin::default() + }; + + // Empty event buffer; integrity poll for class 1/2/3. + // Expected IIN1 = 0x80 (RESTART) | 0x02 (CLASS_1) | 0x04 (CLASS_2) | 0x08 (CLASS_3) = 0x8E. + harness + .test_request_response(READ_CLASS_123, &[0xC0, 0x81, 0x8E, 0x00]) + .await; +} + +#[tokio::test] +async fn application_iin_class_event_fields_default_false_is_a_noop() { + let mut harness = new_harness(get_default_config()); + + // Sanity-check: leaving the new fields at their default `false` produces unchanged + // behavior for an empty-buffer class 1/2/3 poll. Guards against accidental forcing. + harness + .test_request_response(READ_CLASS_123, EMPTY_RESPONSE) + .await; +} + +#[tokio::test] +async fn application_iin_class_1_events_maps_to_iin1_bit_1() { + let mut harness = new_harness(get_default_config()); + harness.application_data.lock().unwrap().application_iin = ApplicationIin { + class_1_events: true, + ..ApplicationIin::default() + }; + // IIN1 = 0x80 (RESTART) | 0x02 (CLASS_1) = 0x82. + harness + .test_request_response(READ_CLASS_123, &[0xC0, 0x81, 0x82, 0x00]) + .await; +} + +#[tokio::test] +async fn application_iin_class_2_events_maps_to_iin1_bit_2() { + let mut harness = new_harness(get_default_config()); + harness.application_data.lock().unwrap().application_iin = ApplicationIin { + class_2_events: true, + ..ApplicationIin::default() + }; + // IIN1 = 0x80 (RESTART) | 0x04 (CLASS_2) = 0x84. + harness + .test_request_response(READ_CLASS_123, &[0xC0, 0x81, 0x84, 0x00]) + .await; +} + +#[tokio::test] +async fn application_iin_class_3_events_maps_to_iin1_bit_3() { + let mut harness = new_harness(get_default_config()); + harness.application_data.lock().unwrap().application_iin = ApplicationIin { + class_3_events: true, + ..ApplicationIin::default() + }; + // IIN1 = 0x80 (RESTART) | 0x08 (CLASS_3) = 0x88. + harness + .test_request_response(READ_CLASS_123, &[0xC0, 0x81, 0x88, 0x00]) + .await; +} + +#[tokio::test] +async fn application_iin_class_event_override_or_s_with_buffer_derived_bits() { + let mut harness = new_harness(get_default_config()); + + // Override asserts class 2 only. + harness.application_data.lock().unwrap().application_iin = ApplicationIin { + class_2_events: true, + ..ApplicationIin::default() + }; + + // Push a class 1 event into the in-memory buffer; do NOT drain it via this read. + harness.handle.database.transaction(|database| { + database.add(0, Some(EventClass::Class1), BinaryInputConfig::default()); + database.update( + 0, + &BinaryInput::new(true, Flags::ONLINE, Time::Synchronized(Timestamp::new(0))), + UpdateOptions::default(), + ); + }); + + // Read class 0 (static data only) so the class 1 event remains unwritten and the + // buffer-derived CLASS_1 bit is set when get_response_iin runs. + // Expected IIN1 = 0x80 (RESTART) | 0x02 (CLASS_1 from buffer) | 0x04 (CLASS_2 from override) = 0x86. + let read_class_0: &[u8] = &[0xC0, 0x01, 0x3C, 0x01, 0x06]; + let expected: &[u8] = &[ + 0xC0, 0x81, 0x86, 0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, + ]; + harness.test_request_response(read_class_0, expected).await; +} diff --git a/dnp3/src/outstation/tests/unsolicited.rs b/dnp3/src/outstation/tests/unsolicited.rs index e507e0f47..7b334d468 100644 --- a/dnp3/src/outstation/tests/unsolicited.rs +++ b/dnp3/src/outstation/tests/unsolicited.rs @@ -5,6 +5,7 @@ use crate::app::parse::parser::{HeaderDetails, ParsedFragment}; use crate::app::{BufferSize, Timestamp}; use crate::outstation::config::OutstationConfig; use crate::outstation::database::*; +use crate::outstation::traits::ApplicationIin; use crate::outstation::{BufferState, ClassCount, TypeCount}; use super::harness::*; @@ -443,3 +444,27 @@ async fn buffer_overflow_issue() { ) .await; } + +#[tokio::test] +async fn application_iin_class_event_override_appears_in_unsolicited_response() { + let mut harness = new_harness(get_default_unsolicited_config()); + confirm_null_unsolicited(&mut harness).await; + enable_unsolicited(&mut harness).await; + + // Override asserts class 2 events on top of whatever the buffer reports. + harness.application_data.lock().unwrap().application_iin = ApplicationIin { + class_2_events: true, + ..ApplicationIin::default() + }; + + generate_binary_event(&mut harness.handle.database); + + // The class 1 event is delivered in this very frame, so by the time get_response_iin runs + // the buffer-derived CLASS_1 bit is already cleared. The override adds CLASS_2 on top. + // IIN1 = 0x80 (RESTART) | 0x04 (CLASS_2 from override) = 0x84. + harness + .expect_response(&[ + 0xF1, 0x82, 0x84, 0x00, 0x02, 0x01, 0x28, 0x01, 0x00, 0x00, 0x00, 0x81, + ]) + .await; +} diff --git a/dnp3/src/outstation/traits.rs b/dnp3/src/outstation/traits.rs index 7c71777f1..033dbf87e 100644 --- a/dnp3/src/outstation/traits.rs +++ b/dnp3/src/outstation/traits.rs @@ -17,6 +17,22 @@ pub struct ApplicationIin { pub device_trouble: bool, /// IIN2.5 Configuration corrupt pub config_corrupt: bool, + /// IIN1.1: Class 1 events available. + /// + /// Set this only if your application maintains an event queue upstream of the DNP3 event + /// buffer and there are pending class 1 events not yet pushed in via `transaction`. The + /// stack already sets this bit automatically when the in-memory event buffer holds class 1 + /// events; this field is OR'd with that bit, so `false` is a no-op. Most applications + /// should leave this `false`. + pub class_1_events: bool, + /// IIN1.2: Class 2 events available. + /// + /// See [`ApplicationIin::class_1_events`] for the same caveats applied to class 2. + pub class_2_events: bool, + /// IIN1.3: Class 3 events available. + /// + /// See [`ApplicationIin::class_1_events`] for the same caveats applied to class 3. + pub class_3_events: bool, } /// Enumeration returned for cold/warm restart diff --git a/ffi/dnp3-ffi/src/outstation/adapters.rs b/ffi/dnp3-ffi/src/outstation/adapters.rs index ef40d1b26..186ca6e6b 100644 --- a/ffi/dnp3-ffi/src/outstation/adapters.rs +++ b/ffi/dnp3-ffi/src/outstation/adapters.rs @@ -248,6 +248,9 @@ impl From for ApplicationIin { local_control: from.local_control(), device_trouble: from.device_trouble(), config_corrupt: from.config_corrupt(), + class_1_events: from.class_1_events(), + class_2_events: from.class_2_events(), + class_3_events: from.class_3_events(), } } } diff --git a/ffi/dnp3-schema/src/outstation.rs b/ffi/dnp3-schema/src/outstation.rs index 81fae3971..ebd60fdbf 100644 --- a/ffi/dnp3-schema/src/outstation.rs +++ b/ffi/dnp3-schema/src/outstation.rs @@ -1051,6 +1051,9 @@ fn define_application_iin(lib: &mut LibraryBuilder) -> BackTraced BackTraced BackTraced