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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
12 changes: 12 additions & 0 deletions dnp3/src/app/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,18 @@ impl BitOr<ApplicationIin> 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
}
}
Expand Down
10 changes: 9 additions & 1 deletion dnp3/src/outstation/tests/harness/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -14,13 +16,15 @@ pub(crate) struct MockOutstationApplication {
pub(crate) struct ApplicationData {
pub(crate) processing_delay: u16,
pub(crate) restart_delay: Option<RestartDelay>,
pub(crate) application_iin: ApplicationIin,
}

impl ApplicationData {
fn new() -> Self {
Self {
processing_delay: 0,
restart_delay: None,
application_iin: ApplicationIin::default(),
}
}
}
Expand All @@ -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(())
Expand Down
99 changes: 99 additions & 0 deletions dnp3/src/outstation/tests/iin.rs
Original file line number Diff line number Diff line change
@@ -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::*;

Expand Down Expand Up @@ -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;
}
25 changes: 25 additions & 0 deletions dnp3/src/outstation/tests/unsolicited.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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;
}
16 changes: 16 additions & 0 deletions dnp3/src/outstation/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions ffi/dnp3-ffi/src/outstation/adapters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ impl From<ffi::ApplicationIin> 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(),
}
}
}
Expand Down
21 changes: 21 additions & 0 deletions ffi/dnp3-schema/src/outstation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,9 @@ fn define_application_iin(lib: &mut LibraryBuilder) -> BackTraced<UniversalStruc
let local_control = Name::create("local_control")?;
let device_trouble = Name::create("device_trouble")?;
let config_corrupt = Name::create("config_corrupt")?;
let class_1_events = Name::create("class_1_events")?;
let class_2_events = Name::create("class_2_events")?;
let class_3_events = Name::create("class_3_events")?;

let application_iin = lib.declare_universal_struct("application_iin")?;
let application_iin = lib
Expand All @@ -1075,6 +1078,21 @@ fn define_application_iin(lib: &mut LibraryBuilder) -> BackTraced<UniversalStruc
Primitive::Bool,
"IIN2.5 - Configuration corrupt",
)?
.add(
class_1_events.clone(),
Primitive::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 a 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.",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The documentation for class_1_events (and the subsequent class event fields) mentions transaction as plain text. To improve the generated documentation for FFI bindings (C++, C#, Java), you should use the oo-bindgen link syntax {method:outstation.transaction()}. This ensures that the documentation in the target languages correctly links to the relevant method, consistent with other parts of the schema.

Suggested change
"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 a 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.",
"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 {method:outstation.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.",

)?
.add(
class_2_events.clone(),
Primitive::Bool,
"IIN1.2 - Class 2 events available. See class_1_events for the same caveats applied to class 2.",
)?
.add(
class_3_events.clone(),
Primitive::Bool,
"IIN1.3 - Class 3 events available. See class_1_events for the same caveats applied to class 3.",
)?
.doc("Application-controlled IIN bits")?
.end_fields()?
.begin_initializer(
Expand All @@ -1086,6 +1104,9 @@ fn define_application_iin(lib: &mut LibraryBuilder) -> BackTraced<UniversalStruc
.default(&local_control, false)?
.default(&device_trouble, false)?
.default(&config_corrupt, false)?
.default(&class_1_events, false)?
.default(&class_2_events, false)?
.default(&class_3_events, false)?
.end_initializer()?
.build()?;

Expand Down