Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion dnp3/src/master/tasks/file/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion dnp3/src/outstation/database/details/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
150 changes: 150 additions & 0 deletions dnp3/src/outstation/database/details/event/buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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));
Expand Down
7 changes: 1 addition & 6 deletions dnp3/src/outstation/database/details/event/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,7 @@ impl<T> VecList<T> {
return Some(entry.create_index(current));
}

match entry.metadata.next {
Some(next) => {
current = next;
}
None => return None,
}
current = entry.metadata.next?;
}
}

Expand Down
26 changes: 25 additions & 1 deletion dnp3/src/outstation/database/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions dnp3/src/outstation/tests/read_states.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
12 changes: 6 additions & 6 deletions dnp3/src/outstation/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
17 changes: 17 additions & 0 deletions ffi/dnp3-ffi/src/outstation/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading