diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6c7cab..50adec7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### 1.7.0-RC2 ### +* :star: Add `Database::discard_unselected_events()` to drop undelivered events of selected classes on demand, e.g. from a disconnect handler. Returns per-class discard counts. Opt-in and lossy by design. See [#427](https://github.com/stepfunc/dnp3/issues/427). * :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/master/tasks/file/mod.rs b/dnp3/src/master/tasks/file/mod.rs index 4db5965a..3732dccc 100644 --- a/dnp3/src/master/tasks/file/mod.rs +++ b/dnp3/src/master/tasks/file/mod.rs @@ -17,7 +17,7 @@ pub(crate) struct Filename(pub(crate) String); // we don't really care what the ID is as we don't support polling for file stuff // we can just be cute and write Step Function (SF) on the wire. -pub(crate) const REQUEST_ID: u16 = u16::from_le_bytes([b'S', b'F']); +pub(crate) const REQUEST_ID: u16 = u16::from_le_bytes(*b"SF"); pub(super) fn write_auth( credentials: &FileCredentials, diff --git a/dnp3/src/outstation/database/details/database.rs b/dnp3/src/outstation/database/details/database.rs index 41308893..e61fe8dd 100644 --- a/dnp3/src/outstation/database/details/database.rs +++ b/dnp3/src/outstation/database/details/database.rs @@ -14,7 +14,7 @@ use crate::app::measurement::{ DoubleBitBinaryInput, Flags, FrozenCounter, Time, }; use crate::outstation::database::details::attrs::map::SetMap; -use crate::outstation::{BufferState, OutstationApplication}; +use crate::outstation::{BufferState, ClassCount, OutstationApplication}; use scursor::WriteCursor; pub(crate) struct Database { @@ -77,6 +77,10 @@ impl Database { } } + pub(crate) fn discard_unselected_events(&mut self, classes: EventClasses) -> ClassCount { + self.event_buffer.remove_unselected_by_class(classes) + } + pub(crate) fn select_event_classes(&mut self, classes: EventClasses) -> usize { self.event_buffer.select_by_class(classes, None) } diff --git a/dnp3/src/outstation/database/details/event/buffer.rs b/dnp3/src/outstation/database/details/event/buffer.rs index 6ccbb4df..cce6dd06 100644 --- a/dnp3/src/outstation/database/details/event/buffer.rs +++ b/dnp3/src/outstation/database/details/event/buffer.rs @@ -633,6 +633,29 @@ impl EventBuffer { count } + pub(crate) fn remove_unselected_by_class(&mut self, classes: EventClasses) -> ClassCount { + let total = &mut self.total; + let mut count = ClassCount::default(); + self.events.remove_all(|event| { + if event.state.get() == EventState::Unselected && classes.matches(event.class) { + total.decrement(event); + match event.class { + EventClass::Class1 => count.num_class_1 += 1, + EventClass::Class2 => count.num_class_2 += 1, + EventClass::Class3 => count.num_class_3 += 1, + } + true + } else { + false + } + }); + + if !self.is_any_full() { + self.is_overflown = false; + } + count + } + pub(crate) fn buffer_state(&self) -> BufferState { self.total.into() } @@ -1026,6 +1049,14 @@ mod tests { } } + fn class_counts(num_class_1: usize, num_class_2: usize, num_class_3: usize) -> ClassCount { + ClassCount { + num_class_1, + num_class_2, + num_class_3, + } + } + fn insert_events(buffer: &mut EventBuffer) { buffer .insert( @@ -1158,6 +1189,125 @@ mod tests { } } + #[test] + fn discards_only_unselected_events_of_matching_classes() { + let mut buffer = EventBuffer::new(EventBufferConfig::all_types(3)); + + insert_events(&mut buffer); + + // events 1 (counter) and 3 (binary) are class 2 + assert_eq!( + class_counts(0, 2, 0), + buffer.remove_unselected_by_class(EventClass::Class2.into()) + ); + assert_eq!( + buffer.unwritten_classes(), + EventClass::Class1 | EventClass::Class3 + ); + + assert_eq!( + class_counts(2, 0, 1), + buffer.remove_unselected_by_class(EventClasses::all()) + ); + assert_eq!(buffer.unwritten_classes(), EventClasses::none()); + + // discarded events never produce further activity + assert_eq!(0, buffer.select_by_class(EventClasses::all(), None)); + } + + #[test] + fn discard_leaves_selected_and_written_events_alone() { + let mut buffer = EventBuffer::new(EventBufferConfig::all_types(3)); + + insert_events(&mut buffer); + + // select the two class 1 events (ids 0 and 4) + assert_eq!(2, buffer.select_by_class(EventClass::Class1.into(), None)); + + // enough space for the binary (id 0) but not the analog (id 4), + // leaving one Written and one still Selected + let mut backing = [0u8; 10]; + let mut cursor = WriteCursor::new(backing.as_mut()); + assert_eq!(buffer.write_events(&mut cursor), Err(1)); + + // only the three unselected events (ids 1, 2, 3) are discarded + assert_eq!( + class_counts(0, 2, 1), + buffer.remove_unselected_by_class(EventClasses::all()) + ); + assert_eq!(buffer.unwritten_classes(), EventClass::Class1.into()); + + // the in-flight events complete their normal confirm flow, + // and event_cleared fires ONLY for them - never for discarded events + let mut mock = MockApplication::default(); + assert_eq!(1, buffer.clear_written(&mut mock)); + assert_eq!(mock.events.pop_front(), Some(Event::Clear(0))); + assert_eq!(mock.events.pop_front(), None); + + // the previously selected analog (id 4) is still deliverable + let mut backing = [0u8; 64]; + let mut cursor = WriteCursor::new(backing.as_mut()); + buffer.reset(); + assert_eq!(1, buffer.select_by_class(EventClasses::all(), None)); + assert_eq!(1, buffer.write_events(&mut cursor).unwrap()); + assert_eq!(1, buffer.clear_written(&mut mock)); + assert_eq!(mock.events.pop_front(), Some(Event::Clear(4))); + assert_eq!(mock.events.pop_front(), None); + } + + #[test] + fn discard_after_reset_removes_all_events() { + let mut buffer = EventBuffer::new(EventBufferConfig::all_types(3)); + + insert_events(&mut buffer); + + // everything is in-flight: discard is a no-op + assert_eq!(5, buffer.select_by_class(EventClasses::all(), None)); + assert!(buffer + .remove_unselected_by_class(EventClasses::all()) + .is_empty()); + + // session teardown re-arms the buffer, making everything eligible + buffer.reset(); + assert_eq!( + class_counts(2, 2, 1), + buffer.remove_unselected_by_class(EventClasses::all()) + ); + assert_eq!(buffer.unwritten_classes(), EventClasses::none()); + } + + #[test] + fn discard_clears_overflow_flag_when_space_is_freed() { + let mut buffer = EventBuffer::new(EventBufferConfig::all_types(1)); + + let binary = BinaryInput::new(true, Flags::ONLINE, Time::synchronized(0)); + + buffer + .insert( + 1, + EventClass::Class1, + &binary, + EventBinaryInputVariation::Group2Var1, + ) + .unwrap(); + + assert!(buffer + .insert( + 1, + EventClass::Class1, + &binary, + EventBinaryInputVariation::Group2Var1, + ) + .is_err()); + + assert!(buffer.is_overflown()); + assert_eq!( + class_counts(1, 0, 0), + buffer.remove_unselected_by_class(EventClass::Class1.into()) + ); + assert!(!buffer.is_overflown()); + } + #[test] fn can_select_events_by_type() { let mut buffer = EventBuffer::new(EventBufferConfig::all_types(3)); diff --git a/dnp3/src/outstation/database/details/event/list.rs b/dnp3/src/outstation/database/details/event/list.rs index 4ab478c5..638d5be9 100644 --- a/dnp3/src/outstation/database/details/event/list.rs +++ b/dnp3/src/outstation/database/details/event/list.rs @@ -259,12 +259,7 @@ impl VecList { return Some(entry.create_index(current)); } - match entry.metadata.next { - Some(next) => { - current = next; - } - None => return None, - } + current = entry.metadata.next?; } } diff --git a/dnp3/src/outstation/database/mod.rs b/dnp3/src/outstation/database/mod.rs index e5e05802..71e91969 100644 --- a/dnp3/src/outstation/database/mod.rs +++ b/dnp3/src/outstation/database/mod.rs @@ -10,7 +10,7 @@ use crate::master::EventClasses; use crate::outstation::database::read::ReadHeader; use crate::app::attr::{AttrProp, AttrSet, OwnedAttribute, TypeError}; -use crate::outstation::OutstationApplication; +use crate::outstation::{ClassCount, OutstationApplication}; use scursor::WriteCursor; mod config; @@ -406,6 +406,30 @@ impl Database { } } + /// Discard undelivered events of the given classes from the event buffer. + /// + /// Only events that are NOT part of an in-flight response/confirm exchange are + /// removed. Returns the number of events discarded on a per-class basis; classes + /// not selected for discard always report zero. + /// + /// This is a lossy operation outside normal event-delivery semantics. Discarded + /// events are gone; they are not re-reported and no confirmation callback fires + /// for them. Discarding may also clear the event buffer overflow (IIN 2.3) flag + /// if it frees enough space. Intended only for specialized integrations that must + /// drop undelivered data on disconnect. Standard outstations should not use it. + /// + /// ```no_run + /// use dnp3::master::EventClasses; + /// use dnp3::outstation::database::DatabaseHandle; + /// + /// fn discard_class_2(handle: &mut DatabaseHandle) { + /// handle.transaction(|db| db.discard_unselected_events(EventClasses::new(false, true, false))); + /// } + /// ``` + pub fn discard_unselected_events(&mut self, classes: EventClasses) -> ClassCount { + self.inner.discard_unselected_events(classes) + } + /// Define an attribute that will be exposed to the master pub fn define_attr( &mut self, diff --git a/dnp3/src/outstation/tests/read_states.rs b/dnp3/src/outstation/tests/read_states.rs index 6fdc44b5..a90aed9f 100644 --- a/dnp3/src/outstation/tests/read_states.rs +++ b/dnp3/src/outstation/tests/read_states.rs @@ -239,3 +239,50 @@ async fn re_arms_unconfirmed_events_after_link_drop() { .await; harness.check_events(&[Event::EnterSolicitedConfirmWait(0)]); } + +// Test for #427: an application that explicitly discards undelivered events from its +// disconnect handler sees them dropped rather than re-reported. The #423 teardown reset +// runs before the application observes the disconnect, so an event that was in-flight +// (Written, awaiting CONFIRM) when the link dropped is back in the unselected state and +// therefore eligible for discard. +#[tokio::test] +async fn discards_unselected_events_on_demand_after_link_drop() { + let mut harness = new_harness(get_default_config()); + + harness.handle.database.transaction(create_binary_and_event); + + // read the event, then drop the link before CONFIRM so it stays in the `Written` state + harness + .test_request_response(READ_CLASS_123, BINARY_EVENT_RESPONSE) + .await; + harness.check_events(&[Event::EnterSolicitedConfirmWait(0)]); + + let harness = harness.drop_link().await; + + // what a bespoke integration would do from its disconnect handler + let discarded = harness + .handle + .database + .transaction(|db| db.discard_unselected_events(crate::master::EventClasses::all())); + assert_eq!( + discarded, + crate::outstation::ClassCount { + num_class_1: 1, + num_class_2: 0, + num_class_3: 0 + } + ); + + let mut harness = harness.reconnect(); + + // no pending events advertised in the class IIN... + harness + .test_request_response(EMPTY_READ, EMPTY_RESPONSE) + .await; + + // ...and nothing re-reported when its class is read + harness + .test_request_response(READ_CLASS_123, EMPTY_RESPONSE) + .await; + harness.check_no_events(); +} diff --git a/dnp3/src/outstation/traits.rs b/dnp3/src/outstation/traits.rs index 7c71777f..9a6806e6 100644 --- a/dnp3/src/outstation/traits.rs +++ b/dnp3/src/outstation/traits.rs @@ -46,19 +46,19 @@ pub enum ConnectionState { Disconnected, } -/// Information about the remaining number of class events in the buffer -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +/// A count of events on a per-class basis +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub struct ClassCount { - /// number of class 1 events remaining in the buffer + /// number of class 1 events pub num_class_1: usize, - /// number of class 2 events remaining in the buffer + /// number of class 2 events pub num_class_2: usize, - /// number of class 3 events remaining in the buffer + /// number of class 3 events pub num_class_3: usize, } impl ClassCount { - /// true if there is no remaining class 1, 2, or 3 data in the buffers + /// true if the count of class 1, 2, and 3 events are all zero pub fn is_empty(&self) -> bool { self.num_class_1 == 0 && self.num_class_2 == 0 && self.num_class_3 == 0 } diff --git a/ffi/dnp3-ffi/src/outstation/database.rs b/ffi/dnp3-ffi/src/outstation/database.rs index fa7b1763..e31bba64 100644 --- a/ffi/dnp3-ffi/src/outstation/database.rs +++ b/ffi/dnp3-ffi/src/outstation/database.rs @@ -115,6 +115,23 @@ macro_rules! implement_database_point_operations { }; } +pub(crate) unsafe fn database_discard_unselected_events( + instance: *mut crate::Database, + classes: ffi::EventClasses, +) -> ffi::ClassCount { + let db = match instance.as_mut() { + None => return dnp3::outstation::ClassCount::default().into(), + Some(db) => db, + }; + + db.discard_unselected_events(dnp3::master::EventClasses::new( + classes.class1, + classes.class2, + classes.class3, + )) + .into() +} + pub(crate) unsafe fn database_update_flags( instance: *mut crate::Database, index: u16, diff --git a/ffi/dnp3-schema/src/database.rs b/ffi/dnp3-schema/src/database.rs index 52800e48..611970c7 100644 --- a/ffi/dnp3-schema/src/database.rs +++ b/ffi/dnp3-schema/src/database.rs @@ -1249,10 +1249,38 @@ pub(crate) fn define_database( // TODO: Add a getter for octet strings + let discard_unselected_events = lib + .define_method("discard_unselected_events", database.clone())? + .doc( + doc("Discard undelivered events of the given classes from the event buffer") + .details("Only events that are NOT part of an in-flight response/confirm exchange are removed.") + .details( + "This is a lossy operation outside normal event-delivery semantics. Discarded \ + events are gone; they are not re-reported and no confirmation callback fires \ + for them. Discarding may also clear the event buffer overflow (IIN 2.3) flag \ + if it frees enough space.", + ) + .warning( + "Intended only for specialized integrations that must drop undelivered data \ + on disconnect. Standard outstations should not use it.", + ), + )? + .param( + "classes", + shared_def.event_classes.clone(), + "Classes of events to discard", + )? + .returns( + shared_def.class_count.clone(), + "Number of events discarded on a per-class basis. Classes not selected for discard always report zero.", + )? + .build()?; + let database = lib .define_class(&database)? // works on all types .method(update_flags)? + .method(discard_unselected_events)? // binary methods .method(add_binary)? .method(remove_binary)? diff --git a/ffi/dnp3-schema/src/master/mod.rs b/ffi/dnp3-schema/src/master/mod.rs index b8af709e..12992f49 100644 --- a/ffi/dnp3-schema/src/master/mod.rs +++ b/ffi/dnp3-schema/src/master/mod.rs @@ -847,7 +847,7 @@ fn define_association_config( lib: &mut LibraryBuilder, shared: &SharedDefinitions, ) -> BackTraced { - let event_classes = define_event_classes(lib)?; + let event_classes = shared.event_classes.clone(); let classes = define_classes(lib)?; let auto_time_sync_enum = lib @@ -1107,43 +1107,6 @@ fn define_association_information( Ok(handle) } -fn define_event_classes(lib: &mut LibraryBuilder) -> BackTraced { - let class1 = Name::create("class1")?; - let class2 = Name::create("class2")?; - let class3 = Name::create("class3")?; - - let event_classes = lib.declare_function_argument_struct("event_classes")?; - let event_classes = lib - .define_function_argument_struct(event_classes)? - .add(&class1, Primitive::Bool, "Class 1 events")? - .add(&class2, Primitive::Bool, "Class 2 events")? - .add(&class3, Primitive::Bool, "Class 3 events")? - .doc("Event classes")? - .end_fields()? - .add_full_initializer("init")? - .begin_initializer( - "all", - InitializerType::Static, - "Initialize all classes to true", - )? - .default(&class1, true)? - .default(&class2, true)? - .default(&class3, true)? - .end_initializer()? - .begin_initializer( - "none", - InitializerType::Static, - "Initialize all classes to false", - )? - .default(&class1, false)? - .default(&class2, false)? - .default(&class3, false)? - .end_initializer()? - .build()?; - - Ok(event_classes) -} - fn define_classes(lib: &mut LibraryBuilder) -> BackTraced { let class0 = Name::create("class0")?; let class1 = Name::create("class1")?; diff --git a/ffi/dnp3-schema/src/outstation.rs b/ffi/dnp3-schema/src/outstation.rs index 81fae397..2dd1a41a 100644 --- a/ffi/dnp3-schema/src/outstation.rs +++ b/ffi/dnp3-schema/src/outstation.rs @@ -32,28 +32,11 @@ impl OutstationTypes { } } -pub(crate) fn define_buffer_state(lib: &mut LibraryBuilder) -> BackTraced { - let class_count = lib.declare_universal_struct("class_count")?; - let class_count = lib - .define_universal_struct(class_count)? - .doc("Remaining number of events in the buffer after a confirm on a per-class basis")? - .add( - "num_class_1", - Primitive::U32, - "Number of class 1 events remaining in the buffer", - )? - .add( - "num_class_2", - Primitive::U32, - "Number of class 2 events remaining in the buffer", - )? - .add( - "num_class_3", - Primitive::U32, - "Number of class 3 events remaining in the buffer", - )? - .end_fields()? - .build()?; +pub(crate) fn define_buffer_state( + lib: &mut LibraryBuilder, + shared: &SharedDefinitions, +) -> BackTraced { + let class_count = shared.class_count.clone(); let type_count = lib.declare_universal_struct("type_count")?; let type_count = lib @@ -1166,7 +1149,7 @@ fn define_outstation_application( let application_iin = define_application_iin(lib)?; - let buffer_state = define_buffer_state(lib)?; + let buffer_state = define_buffer_state(lib, shared)?; let freeze_not_supported = freeze_result.value("not_supported")?; diff --git a/ffi/dnp3-schema/src/shared.rs b/ffi/dnp3-schema/src/shared.rs index c189f17a..e82d322e 100644 --- a/ffi/dnp3-schema/src/shared.rs +++ b/ffi/dnp3-schema/src/shared.rs @@ -15,6 +15,8 @@ pub(crate) struct SharedDefinitions { pub connect_options: ClassHandle, pub endpoint_list: ClassHandle, pub connect_strategy: FunctionArgStructHandle, + pub event_classes: FunctionArgStructHandle, + pub class_count: UniversalStructHandle, pub tls_client_config: FunctionArgStructHandle, pub levels: DecodeLevels, pub decode_level: UniversalStructHandle, @@ -326,6 +328,8 @@ pub(crate) fn define(lib: &mut LibraryBuilder) -> BackTraced connect_options, endpoint_list, connect_strategy, + event_classes: define_event_classes(lib)?, + class_count: define_class_count(lib)?, tls_client_config: tls.tls_client_config, levels, decode_level, @@ -370,6 +374,57 @@ pub(crate) fn define(lib: &mut LibraryBuilder) -> BackTraced }) } +fn define_event_classes(lib: &mut LibraryBuilder) -> BackTraced { + let class1 = Name::create("class1")?; + let class2 = Name::create("class2")?; + let class3 = Name::create("class3")?; + + let event_classes = lib.declare_function_argument_struct("event_classes")?; + let event_classes = lib + .define_function_argument_struct(event_classes)? + .add(&class1, Primitive::Bool, "Class 1 events")? + .add(&class2, Primitive::Bool, "Class 2 events")? + .add(&class3, Primitive::Bool, "Class 3 events")? + .doc("Event classes")? + .end_fields()? + .add_full_initializer("init")? + .begin_initializer( + "all", + InitializerType::Static, + "Initialize all classes to true", + )? + .default(&class1, true)? + .default(&class2, true)? + .default(&class3, true)? + .end_initializer()? + .begin_initializer( + "none", + InitializerType::Static, + "Initialize all classes to false", + )? + .default(&class1, false)? + .default(&class2, false)? + .default(&class3, false)? + .end_initializer()? + .build()?; + + Ok(event_classes) +} + +fn define_class_count(lib: &mut LibraryBuilder) -> BackTraced { + let class_count = lib.declare_universal_struct("class_count")?; + let class_count = lib + .define_universal_struct(class_count)? + .doc("A count of events on a per-class basis")? + .add("num_class_1", Primitive::U32, "Number of class 1 events")? + .add("num_class_2", Primitive::U32, "Number of class 2 events")? + .add("num_class_3", Primitive::U32, "Number of class 3 events")? + .end_fields()? + .build()?; + + Ok(class_count) +} + fn define_nothing_enum(lib: &mut LibraryBuilder) -> BackTraced { let nothing = lib .define_enum("nothing")?