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
39 changes: 39 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Fuzz smoke

on:
pull_request:
paths:
- .github/workflows/fuzz.yml
- src/**
- tests/OpenDisNet.Fuzz/**
- Directory.Build.props
- OpenDisNet.slnx
push:
branches: [main]
paths:
- .github/workflows/fuzz.yml
- src/**
- tests/OpenDisNet.Fuzz/**
- Directory.Build.props
- OpenDisNet.slnx
schedule:
- cron: "43 4 * * 3"
workflow_dispatch:

permissions:
contents: read

jobs:
parser:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.x
- run: dotnet restore --locked-mode
- name: Build fuzz target
run: dotnet build tests/OpenDisNet.Fuzz/OpenDisNet.Fuzz.csproj --configuration Release --no-restore --no-incremental
- name: Run bounded parser fuzz smoke corpus
run: dotnet run --project tests/OpenDisNet.Fuzz/OpenDisNet.Fuzz.csproj --configuration Release --no-build --no-restore -- --smoke
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,20 @@ All notable changes are documented here. This project follows Semantic
Versioning starting with 1.0 and uses prerelease versions when conformance is
incomplete.

## 1.1.0 - Unreleased
## 1.2.0 - Unreleased

- Added framed span and segmented-sequence readers that distinguish incomplete
input from invalid data and report the number of consumed octets.
- Added header-only inspection and safe raw preservation for explicitly allowed
non-v7 datagrams instead of interpreting them with v7 body layouts.
- Added builders for Entity State, Fire, Detonation, and Transmitter PDUs plus
non-mutating semantic validation with structured warnings and errors.
- Expanded BenchmarkDotNet coverage to fixed, variable, vendor-defined, framed,
header-only, and invalid-input paths.
- Added a SharpFuzz parser target, deterministic mutation corpus, and scheduled
CI smoke-fuzz workflow.

## 1.1.0 - 2026-07-25

- Added first-class .NET 8 support alongside .NET 9 and .NET 10, including
full unit-test, package validation, and packed-package consumer coverage.
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ vectors, round-trip tests, and malformed-input tests. Run `dotnet test
--configuration Release` before opening a pull request. Do not submit IEEE
specification text or non-public operational packet captures.

Parser and framing changes should also run the bounded fuzz corpus:

```shell
dotnet run --project tests/OpenDisNet.Fuzz -c Release -- --smoke
```

See [`docs/fuzzing.md`](docs/fuzzing.md) before starting a sustained SharpFuzz
campaign or contributing a minimized crash regression.

The 1.x public API follows semantic versioning. Additive API changes must update
`src/OpenDisNet/PublicAPI.Unshipped.txt`, include tests, and describe the
consumer benefit in the changelog. Removals or incompatible signature changes
Expand Down
1 change: 1 addition & 0 deletions OpenDisNet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<Project Path="src/OpenDisNet/OpenDisNet.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/OpenDisNet.Fuzz/OpenDisNet.Fuzz.csproj" />
<Project Path="tests/OpenDisNet.Tests/OpenDisNet.Tests.csproj" />
</Folder>
<Folder Name="/tools/">
Expand Down
60 changes: 57 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

[![CI](https://github.com/RejectKid/OpenDisNet/actions/workflows/ci.yml/badge.svg)](https://github.com/RejectKid/OpenDisNet/actions/workflows/ci.yml)
[![Benchmarks](https://github.com/RejectKid/OpenDisNet/actions/workflows/benchmarks.yml/badge.svg)](https://github.com/RejectKid/OpenDisNet/actions/workflows/benchmarks.yml)
[![Fuzz smoke](https://github.com/RejectKid/OpenDisNet/actions/workflows/fuzz.yml/badge.svg)](https://github.com/RejectKid/OpenDisNet/actions/workflows/fuzz.yml)
[![NuGet](https://img.shields.io/nuget/v/OpenDisNet.svg)](https://www.nuget.org/packages/OpenDisNet)
[![GitHub Release](https://img.shields.io/github/v/release/RejectKid/OpenDisNet)](https://github.com/RejectKid/OpenDisNet/releases/latest)

Expand Down Expand Up @@ -45,6 +46,55 @@ Unknown and vendor-defined PDU bodies are retained rather than discarded.
Use `DisSerializer.Serialize(pdu)` for the reverse operation. See the
[public API design](docs/api-design.md) for the supported design rules.

When reading packet captures, pipelines, or buffers containing multiple PDUs,
use the framed API. It distinguishes incomplete input from invalid input and
reports exactly how many octets to advance:

```csharp
DisReadStatus status = DisSerializer.TryRead(
buffer,
out IDisPdu? pdu,
out int consumed,
out DisParseError error);

if (status == DisReadStatus.Done)
buffer = buffer[consumed..];
```

The same API accepts `ReadOnlySequence<byte>`. `TryReadHeader` inspects routing
fields without decoding or allocating a PDU body. If version enforcement is
explicitly disabled, non-v7 bodies are returned as `UnknownPdu`; they are never
interpreted using a v7 layout.

## Build and validate common PDUs

`DisPduBuilder` establishes discriminators and related fields for common
workflows. Semantic validation remains separate from bounded wire parsing:

```csharp
using OpenDisNet.Validation;

FirePdu fire = DisPduBuilder.CreateFire(
firingEntity,
targetEntity,
munitionEntity,
42,
descriptor,
launchLocation,
velocity,
range: 5_000,
exerciseId: 1);

DisValidationResult validation = DisValidator.Validate(fire);
foreach (DisValidationIssue issue in validation.Issues)
Console.WriteLine($"{issue.Severity}: {issue.Path}: {issue.Message}");
```

Builders are also available for Entity State, Detonation, and Transmitter PDUs.
Validation reports discriminator inconsistencies, non-finite coordinates,
invalid physical values, incomplete radio state, and unset primary identifiers
without changing the PDU.

## Create and serialize a Signal PDU

Signal data normally comes from an audio codec, tactical-data-link implementation,
Expand Down Expand Up @@ -135,9 +185,9 @@ and checked big-endian primitives are under

## Benchmarks

The BenchmarkDotNet suite measures typed parsing, non-throwing parsing, allocated
serialization, and serialization into caller-owned storage for representative
Signal PDU payload sizes. Run it on any supported runtime:
The BenchmarkDotNet suite measures typed, framed, and header-only parsing plus
caller-owned serialization across Signal payload sizes and representative fixed,
variable, vendor-defined, and malformed PDUs. Run it on any supported runtime:

```shell
dotnet run --project benchmarks/OpenDisNet.Benchmarks -c Release -f net10.0
Expand All @@ -158,6 +208,10 @@ GitHub-hosted runners are appropriate for comparing runtimes within one run.
Use controlled, dedicated hardware before treating results from different runs
as a strict performance regression gate.

The parser also has a coverage-guided SharpFuzz target and a bounded CI mutation
campaign. See [parser fuzzing](docs/fuzzing.md) for local smoke, corpus creation,
instrumentation, and sustained fuzzing instructions.

## Security

Treat network datagrams as untrusted. See [`SECURITY.md`](SECURITY.md) for the
Expand Down
6 changes: 5 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ not authorization decisions.
- Low-or-higher vulnerabilities in direct or transitive NuGet dependencies fail
restore.
- Pull requests receive dependency review, cross-platform tests, parser hostile-
input regression tests, formatting checks, and CodeQL analysis.
input regression tests, bounded parser fuzzing, formatting checks, and CodeQL
analysis.
- Weekly CodeQL analysis covers changes outside pull-request activity.
- A SharpFuzz target exercises header, datagram, framed-span, segmented-sequence,
validation, serialization, and round-trip paths; its bounded mutation corpus
runs in CI and supports sustained coverage-guided campaigns.
- Release packages include SHA-256 checksums and GitHub build-provenance
attestations and are published to NuGet through short-lived OIDC credentials.
52 changes: 52 additions & 0 deletions benchmarks/OpenDisNet.Benchmarks/InvalidInputBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using BenchmarkDotNet.Attributes;
using OpenDisNet.Pdus;

namespace OpenDisNet.Benchmarks;

public enum InvalidInputScenario
{
TruncatedHeader,
TruncatedPdu,
RandomKilobyte,
}

[MemoryDiagnoser]
public class InvalidInputBenchmarks
{
private byte[] _datagram = null!;

[ParamsAllValues]
public InvalidInputScenario Scenario { get; set; }

[GlobalSetup]
public void Setup()
{
_datagram = Scenario switch
{
InvalidInputScenario.TruncatedHeader => [7, 1, 1],
InvalidInputScenario.TruncatedPdu => CreateTruncatedPdu(),
InvalidInputScenario.RandomKilobyte => CreateRandomInput(),
_ => throw new ArgumentOutOfRangeException(),
};
}

[Benchmark(Baseline = true)]
public bool TryDeserialize() => DisSerializer.TryDeserialize(_datagram, out _, out _);

[Benchmark]
public DisReadStatus TryRead() => DisSerializer.TryRead(_datagram, out _, out _, out _);

private static byte[] CreateTruncatedPdu()
{
var signal = new SignalPdu { Radio = new RadioId(new EntityId(1, 1, 1), 1) };
signal.SetData(new byte[160]);
return DisSerializer.Serialize(signal)[..^1];
}

private static byte[] CreateRandomInput()
{
var bytes = new byte[1024];
new Random(42).NextBytes(bytes);
return bytes;
}
}
88 changes: 88 additions & 0 deletions benchmarks/OpenDisNet.Benchmarks/RepresentativePduBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using BenchmarkDotNet.Attributes;
using OpenDisNet.Enumerations;
using OpenDisNet.Pdus;
using OpenDisNet.Protocol;

namespace OpenDisNet.Benchmarks;

public enum PduScenario
{
EntityState,
Fire,
Transmitter,
UnknownVendor,
}

[MemoryDiagnoser]
public class RepresentativePduBenchmarks
{
private IDisPdu _pdu = null!;
private byte[] _datagram = null!;
private byte[] _destination = null!;

[ParamsAllValues]
public PduScenario Scenario { get; set; }

[GlobalSetup]
public void Setup()
{
_pdu = CreatePdu(Scenario);
_datagram = DisSerializer.Serialize(_pdu);
_destination = new byte[_datagram.Length];
}

[Benchmark(Baseline = true)]
public IDisPdu Deserialize() => DisSerializer.Deserialize(_datagram);

[Benchmark]
public DisReadStatus TryRead() => DisSerializer.TryRead(_datagram, out _, out _, out _);

[Benchmark]
public bool TryReadHeader() => DisSerializer.TryReadHeader(_datagram, out _, out _);

[Benchmark]
public int SerializeCallerOwned() => DisSerializer.Serialize(_pdu, _destination);

private static IDisPdu CreatePdu(PduScenario scenario) => scenario switch
{
PduScenario.EntityState => DisPduBuilder.CreateEntityState(
new EntityId(1, 10, 42),
new EntityType
{
EntityKind = EntityKind.Platform,
Domain = new Domain { Value = (byte)PlatformDomain.Air },
Country = Country.UnitedStatesOfAmericaUsa,
Category = 1,
},
new Vector3Double { X = 1_000, Y = 2_000, Z = 3_000 },
ForceId.Friendly,
exerciseId: 1),
PduScenario.Fire => DisPduBuilder.CreateFire(
new EntityId(1, 10, 42),
new EntityId(1, 10, 43),
new EntityId(1, 10, 44),
7,
new MunitionDescriptor { Quantity = 1 },
new Vector3Double { X = 100, Y = 200, Z = 300 },
new Vector3Float { X = 400, Y = 500, Z = 600 },
range: 5_000,
exerciseId: 1),
PduScenario.Transmitter => CreateTransmitter(),
PduScenario.UnknownVendor => new UnknownPdu(
new DisHeader(DisProtocolVersion.Ieee1278_1_2012, 1, (PduType)200, (ProtocolFamily)200, 42, 0, 0, 0),
Enumerable.Range(0, 256).Select(index => unchecked((byte)(index * 31))).ToArray()),
_ => throw new ArgumentOutOfRangeException(nameof(scenario)),
};

private static TransmitterPdu CreateTransmitter()
{
TransmitterPdu transmitter = DisPduBuilder.CreateTransmitter(
new RadioId(new EntityId(1, 10, 42), 7),
new RadioType(),
frequency: 225_000_000,
power: 50,
exerciseId: 1);
transmitter.ModulationParameters = Enumerable.Range(0, 64).Select(index => (byte)index).ToArray();
return transmitter;
}
}
27 changes: 27 additions & 0 deletions docs/fuzzing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Parser fuzzing

`OpenDisNet.Fuzz` is a SharpFuzz 2.3 harness for the public header, datagram,
framed-span, and segmented-sequence parsing paths. Successful parses are also
semantically validated, serialized, and parsed again. Unexpected exceptions or
differences between contiguous and segmented framing are treated as crashes.

Run the bounded deterministic smoke corpus used by CI:

```shell
dotnet run --project tests/OpenDisNet.Fuzz -c Release -- --smoke
```

Create a starting corpus containing all 72 standardized PDU types plus malformed
and non-v7 inputs:

```shell
dotnet run --project tests/OpenDisNet.Fuzz -c Release -- --write-corpus artifacts/fuzz-corpus
```

For a sustained coverage-guided campaign, install AFL++ and the
`SharpFuzz.CommandLine` tool, instrument the Release build of
`OpenDisNet.Fuzz.dll`, and run AFL++ against the generated corpus. Follow the
[SharpFuzz usage instructions](https://github.com/Metalnem/SharpFuzz#usage) for
the current instrumentation and runner commands. Fuzz findings and generated
corpora belong under `artifacts/` and must not be committed without minimizing
them and adding a focused regression test.
4 changes: 2 additions & 2 deletions docs/release-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ package validation compares every new artifact with the stable 1.0.0 baseline.

## Install an RC

Release candidates use SemVer tags such as `v1.1.0-rc.1`. After a candidate is
Release candidates use SemVer tags such as `vX.Y.Z-rc.1`. After a candidate is
published, install that exact version so test results remain reproducible:

```shell
dotnet add package OpenDisNet --version 1.1.0-rc.1
dotnet add package OpenDisNet --version <next-version>-rc.1
```

GitHub and NuGet identify RC builds as prereleases. Do not use an RC in a
Expand Down
2 changes: 1 addition & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ publishes to NuGet.org, and creates a GitHub release for tags such as `v1.0.1`.
GitHub-generated notes are categorized using `.github/release.yml`, and package
plus symbol artifacts are attached to the release shown on the repository page.

Prerelease tags use SemVer identifiers such as `v1.1.0-rc.1`. The workflow
Prerelease tags use SemVer identifiers such as `vX.Y.Z-rc.1`. The workflow
derives the package version from the tag and marks the GitHub release as a
prerelease automatically. Before publishing any tag, the workflow validates the
public API, compares the package with the 1.0.0 compatibility baseline, and runs
Expand Down
14 changes: 14 additions & 0 deletions src/OpenDisNet/DisReadStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace OpenDisNet;

/// <summary>Describes the outcome of reading one framed DIS PDU from a buffer.</summary>
public enum DisReadStatus
{
/// <summary>One complete PDU was decoded.</summary>
Done,

/// <summary>The buffer ended before the complete PDU was available.</summary>
NeedMoreData,

/// <summary>The buffer contains an invalid DIS header or PDU.</summary>
InvalidData,
}
Loading
Loading