Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ Version numbers follow [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Breaking Changes

- **DNP3 analyzer output: renamed summary key `total_parse_errors` → `parse_errors`.**
The `detail` map produced by the DNP3 analyzer now uses the key `"parse_errors"` instead of
`"total_parse_errors"`, aligning DNP3 with sibling analyzers (HTTP, TLS, Modbus) that already
use `"parse_errors"`. JSON consumers reading DNP3 summary output must migrate the key name.
[PC-014, BC-2.15.020 v1.4, STORY-108 AC-010]

**Migration:** Replace any lookup of `detail["total_parse_errors"]` with
`detail["parse_errors"]` in your consumer. For `jq` users:
`jq '.[] | .detail.total_parse_errors'` → `jq '.[] | .detail.parse_errors'`.

## [0.9.4] - 2026-06-23

### Added
Expand Down
8 changes: 4 additions & 4 deletions src/analyzer/dnp3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1372,7 +1372,7 @@ impl Dnp3Analyzer {
///
/// Aggregates across all flows: `function_code_distribution` (from
/// `self.fn_code_counts`, zero-count FCs suppressed), `control_operation_counts`
/// (per-flow `direct_operate_count`), `total_frames`, `total_parse_errors`,
/// (per-flow `direct_operate_count`), `total_frames`, `parse_errors`,
/// `flows_analyzed`. Returns a populated `AnalysisSummary` even when zero flows
/// were processed (all counts zero, maps empty — BC-2.15.020 postcondition 2).
///
Expand All @@ -1382,7 +1382,7 @@ impl Dnp3Analyzer {

let flows_analyzed = self.flows.len() as u64;
let total_frames: u64 = self.flows.values().map(|f| f.frame_count).sum();
let total_parse_errors: u64 = self.flows.values().map(|f| f.parse_errors).sum();
let aggregate_parse_errors: u64 = self.flows.values().map(|f| f.parse_errors).sum();

// BC-2.15.020 postcondition 1: function_code_distribution — only FCs with count > 0.
// Keys are decimal strings of the FC byte (e.g. "5" for 0x05 DIRECT_OPERATE).
Expand Down Expand Up @@ -1422,8 +1422,8 @@ impl Dnp3Analyzer {
serde_json::Value::Number(total_frames.into()),
);
detail.insert(
"total_parse_errors".to_string(),
serde_json::Value::Number(total_parse_errors.into()),
"parse_errors".to_string(),
serde_json::Value::Number(aggregate_parse_errors.into()),
);
detail.insert(
"flows_analyzed".to_string(),
Expand Down
4 changes: 2 additions & 2 deletions tests/bc_2_15_110_dnp3_dispatcher_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -956,9 +956,9 @@ mod story_110 {
let dnp3 = dispatcher.take_dnp3_analyzer().unwrap();
// The carry mechanism must have assembled the full frame and processed it.
// parse_errors == 0 (no structural failure from the split).
let total_parse_errors: u64 = dnp3.flows.values().map(|f| f.parse_errors).sum();
let parse_errors: u64 = dnp3.flows.values().map(|f| f.parse_errors).sum();
assert_eq!(
total_parse_errors, 0,
parse_errors, 0,
"EC-003: split frame across two on_data calls must produce ZERO parse errors \
(carry buffer reassembles the frame cleanly)"
);
Expand Down
63 changes: 54 additions & 9 deletions tests/dnp3_detection_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -990,14 +990,14 @@ mod story_108 {
"AC-011: total_frames must be 0 when no flows processed"
);

let total_parse_errors = summary
let parse_errors = summary
.detail
.get("total_parse_errors")
.get("parse_errors")
.and_then(|v| v.as_u64())
.unwrap_or(u64::MAX);
assert_eq!(
total_parse_errors, 0,
"AC-011: total_parse_errors must be 0 when no flows processed"
parse_errors, 0,
"AC-011: parse_errors must be 0 when no flows processed"
);

// function_code_distribution must be present (as empty object, not absent)
Expand Down Expand Up @@ -1352,10 +1352,10 @@ mod story_108 {
}

// -----------------------------------------------------------------------
// total_parse_errors in summary
// parse_errors in summary
// -----------------------------------------------------------------------

/// Verifies summarize() includes total_parse_errors (BC-2.15.020 postcondition 1).
/// Verifies summarize() includes parse_errors (BC-2.15.020 postcondition 1).
///
/// Traces to: BC-2.15.020 postcondition 1; STORY-108 AC-011.
#[test]
Expand All @@ -1373,10 +1373,10 @@ mod story_108 {

let summary = analyzer.summarize();

// total_parse_errors must be present
// parse_errors must be present
assert!(
summary.detail.contains_key("total_parse_errors"),
"BC-2.15.020: total_parse_errors key must be present in summary detail"
summary.detail.contains_key("parse_errors"),
"BC-2.15.020: parse_errors key must be present in summary detail"
);
}

Expand Down Expand Up @@ -1569,4 +1569,49 @@ mod story_108 {
f.source_ip
);
}
// -----------------------------------------------------------------------
// Red Gate — BC-2.15.020 v1.4: parse_errors key rename (D-220, human-approved)
//
// This test MUST FAIL on current code (which emits "total_parse_errors") and
// MUST PASS after the implementer renames the key to "parse_errors" in
// src/analyzer/dnp3.rs:1425.
//
// Traces to: BC-2.15.020 v1.4 postcondition 1 (BREAKING rename D-220);
// scope.md PC-014; test-vector row "Red Gate — key name".
// -----------------------------------------------------------------------

/// BC-2.15.020 v1.4 Red Gate: summarize() detail map MUST use key "parse_errors",
/// NOT "total_parse_errors" (D-220 breaking rename — aligns DNP3 with HTTP/TLS/Modbus).
///
/// Traces to: BC-2.15.020 v1.4 postcondition 1; PC-014 scope.md §4.
#[test]
fn test_BC_2_15_020_parse_errors_key_name_is_parse_errors() {
let mut analyzer = Dnp3Analyzer::new(10);
let key = test_flow_key();

// Deliver a frame with invalid LENGTH (4 < 5) to produce a parse error,
// mirroring the approach used by test_BC_2_15_020_summarize_includes_parse_errors.
let mut bad_frame = vec![0u8; 10];
bad_frame[0] = 0x05;
bad_frame[1] = 0x64;
bad_frame[2] = 4; // invalid LENGTH — triggers parse error counter
bad_frame[3] = 0xC4;
analyzer.on_data(key.clone(), &bad_frame, 0);

let summary = analyzer.summarize();

// MUST be present under the new canonical key name (post-D-220).
assert!(
summary.detail.contains_key("parse_errors"),
"BC-2.15.020 v1.4 D-220: detail map must contain \"parse_errors\" \
(aligns with HTTP/TLS/Modbus sibling analyzers)"
);

// MUST NOT be present under the old divergent key name.
assert!(
!summary.detail.contains_key("total_parse_errors"),
"BC-2.15.020 v1.4 D-220: detail map must NOT contain \"total_parse_errors\" \
(old key removed by rename; callers must migrate to \"parse_errors\")"
);
}
} // mod story_108