diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..f10b7a9 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index abb61a9..5a8505b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8974c05..e1a7895 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/OpenDisNet.slnx b/OpenDisNet.slnx index 2c9e95a..f333b10 100644 --- a/OpenDisNet.slnx +++ b/OpenDisNet.slnx @@ -9,6 +9,7 @@ + diff --git a/README.md b/README.md index a2c26b1..6c3209a 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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`. `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, @@ -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 @@ -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 diff --git a/SECURITY.md b/SECURITY.md index e0d5a04..f1ddcf9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/benchmarks/OpenDisNet.Benchmarks/InvalidInputBenchmarks.cs b/benchmarks/OpenDisNet.Benchmarks/InvalidInputBenchmarks.cs new file mode 100644 index 0000000..077a8b0 --- /dev/null +++ b/benchmarks/OpenDisNet.Benchmarks/InvalidInputBenchmarks.cs @@ -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; + } +} diff --git a/benchmarks/OpenDisNet.Benchmarks/RepresentativePduBenchmarks.cs b/benchmarks/OpenDisNet.Benchmarks/RepresentativePduBenchmarks.cs new file mode 100644 index 0000000..de364cc --- /dev/null +++ b/benchmarks/OpenDisNet.Benchmarks/RepresentativePduBenchmarks.cs @@ -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; + } +} diff --git a/docs/fuzzing.md b/docs/fuzzing.md new file mode 100644 index 0000000..9d05f5a --- /dev/null +++ b/docs/fuzzing.md @@ -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. diff --git a/docs/release-candidate.md b/docs/release-candidate.md index 69b0ede..f567b7c 100644 --- a/docs/release-candidate.md +++ b/docs/release-candidate.md @@ -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 -rc.1 ``` GitHub and NuGet identify RC builds as prereleases. Do not use an RC in a diff --git a/docs/releasing.md b/docs/releasing.md index 9fdb778..ea12ec1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 diff --git a/src/OpenDisNet/DisReadStatus.cs b/src/OpenDisNet/DisReadStatus.cs new file mode 100644 index 0000000..b3f97ef --- /dev/null +++ b/src/OpenDisNet/DisReadStatus.cs @@ -0,0 +1,14 @@ +namespace OpenDisNet; + +/// Describes the outcome of reading one framed DIS PDU from a buffer. +public enum DisReadStatus +{ + /// One complete PDU was decoded. + Done, + + /// The buffer ended before the complete PDU was available. + NeedMoreData, + + /// The buffer contains an invalid DIS header or PDU. + InvalidData, +} diff --git a/src/OpenDisNet/DisSerializer.cs b/src/OpenDisNet/DisSerializer.cs index 975f7a3..e5e0062 100644 --- a/src/OpenDisNet/DisSerializer.cs +++ b/src/OpenDisNet/DisSerializer.cs @@ -1,3 +1,4 @@ +using System.Buffers; using OpenDisNet.Binary; using OpenDisNet.Pdus; using OpenDisNet.Protocol; @@ -7,6 +8,129 @@ namespace OpenDisNet; /// Serializes and deserializes Distributed Interactive Simulation PDUs. public static class DisSerializer { + /// Attempts to inspect a DIS header without decoding its PDU body. + public static bool TryReadHeader(ReadOnlySpan source, out DisHeader header, out DisParseError error) + { + header = default; + error = default; + + if (source.Length < 4) + return Fail(DisParseErrorCode.TruncatedHeader, "A DIS header requires at least 4 bytes to identify its layout.", source.Length, out error); + + int requiredHeaderSize = RequiredHeaderSize(source[3]); + if (source.Length < requiredHeaderSize) + return Fail(DisParseErrorCode.TruncatedHeader, $"This DIS header requires {requiredHeaderSize} bytes.", source.Length, out error); + + try + { + var reader = new DisBinaryReader(source[..requiredHeaderSize]); + header = DisHeaderCodec.Read(ref reader); + if (header.Length < requiredHeaderSize) + return Fail(DisParseErrorCode.InvalidLength, $"Invalid PDU length {header.Length}.", 8, out error); + return true; + } + catch (DisParseException exception) + { + return Fail(DisParseErrorCode.InvalidField, exception.Message, exception.Offset, out error); + } + } + + /// Attempts to inspect a possibly segmented DIS header without decoding its PDU body. + public static bool TryReadHeader(ReadOnlySequence source, out DisHeader header, out DisParseError error) + { + int available = (int)Math.Min(source.Length, DisHeader.Size); + if (source.IsSingleSegment) + return TryReadHeader(source.FirstSpan[..available], out header, out error); + + Span headerBytes = stackalloc byte[DisHeader.Size]; + source.Slice(0, available).CopyTo(headerBytes); + return TryReadHeader(headerBytes[..available], out header, out error); + } + + /// Attempts to read the first complete DIS PDU from a buffer. + public static DisReadStatus TryRead( + ReadOnlySpan source, + out IDisPdu? pdu, + out int bytesConsumed, + out DisParseError error) => + TryRead(source, out pdu, out bytesConsumed, out error, null); + + /// Attempts to read the first complete DIS PDU from a buffer with explicit parse options. + public static DisReadStatus TryRead( + ReadOnlySpan source, + out IDisPdu? pdu, + out int bytesConsumed, + out DisParseError error, + DisParseOptions? options) + { + pdu = null; + bytesConsumed = 0; + if (!TryReadHeader(source, out DisHeader header, out error)) + return error.Code == DisParseErrorCode.TruncatedHeader ? DisReadStatus.NeedMoreData : DisReadStatus.InvalidData; + + options ??= DisParseOptions.Default; + if (header.Length > options.MaximumPduLength) + { + Fail(DisParseErrorCode.InvalidLength, $"Invalid PDU length {header.Length}.", 8, out error); + return DisReadStatus.InvalidData; + } + + if (source.Length < header.Length) + { + Fail(DisParseErrorCode.TruncatedPdu, $"The header declares {header.Length} bytes; only {source.Length} were received.", source.Length, out error); + return DisReadStatus.NeedMoreData; + } + + DisParseOptions framedOptions = options with { RequireExactDatagramLength = true }; + if (!TryDeserialize(source[..header.Length], out pdu, out error, framedOptions)) + return DisReadStatus.InvalidData; + + bytesConsumed = header.Length; + return DisReadStatus.Done; + } + + /// Attempts to read the first complete DIS PDU from a possibly segmented buffer. + public static DisReadStatus TryRead( + ReadOnlySequence source, + out IDisPdu? pdu, + out int bytesConsumed, + out DisParseError error) => + TryRead(source, out pdu, out bytesConsumed, out error, null); + + /// Attempts to read the first complete DIS PDU from a possibly segmented buffer with explicit parse options. + public static DisReadStatus TryRead( + ReadOnlySequence source, + out IDisPdu? pdu, + out int bytesConsumed, + out DisParseError error, + DisParseOptions? options) + { + pdu = null; + bytesConsumed = 0; + if (!TryReadHeader(source, out DisHeader header, out error)) + return error.Code == DisParseErrorCode.TruncatedHeader ? DisReadStatus.NeedMoreData : DisReadStatus.InvalidData; + + options ??= DisParseOptions.Default; + if (header.Length > options.MaximumPduLength) + { + Fail(DisParseErrorCode.InvalidLength, $"Invalid PDU length {header.Length}.", 8, out error); + return DisReadStatus.InvalidData; + } + + if (source.Length < header.Length) + { + Fail(DisParseErrorCode.TruncatedPdu, $"The header declares {header.Length} bytes; only {source.Length} were received.", (int)Math.Min(source.Length, int.MaxValue), out error); + return DisReadStatus.NeedMoreData; + } + + ReadOnlySequence frame = source.Slice(0, header.Length); + if (frame.IsSingleSegment) + return TryRead(frame.FirstSpan, out pdu, out bytesConsumed, out error, options); + + byte[] contiguousFrame = frame.ToArray(); + return TryRead(contiguousFrame, out pdu, out bytesConsumed, out error, options); + } + /// Deserializes one complete DIS datagram and requires the specified PDU type. public static TPdu Deserialize(ReadOnlySpan datagram, DisParseOptions? options = null) where TPdu : class, IDisPdu @@ -51,19 +175,11 @@ public static bool TryDeserialize( pdu = null; error = default; - if (datagram.Length < 4) - return Fail(DisParseErrorCode.TruncatedHeader, "A DIS header requires at least 4 bytes to identify its layout.", datagram.Length, out error); - - int requiredHeaderSize = datagram[3] == (byte)ProtocolFamily.LiveEntity - ? DisHeader.MinimumSize - : DisHeader.Size; - if (datagram.Length < requiredHeaderSize) - return Fail(DisParseErrorCode.TruncatedHeader, $"This DIS header requires {requiredHeaderSize} bytes.", datagram.Length, out error); + if (!TryReadHeader(datagram, out DisHeader header, out error)) + return false; try { - var reader = new DisBinaryReader(datagram); - DisHeader header = DisHeaderCodec.Read(ref reader); int headerSize = header.EncodedSize; if (options.RequireVersion7 && header.ProtocolVersion != DisProtocolVersion.Ieee1278_1_2012) @@ -75,7 +191,10 @@ public static bool TryDeserialize( if (options.RequireExactDatagramLength && header.Length != datagram.Length) return Fail(DisParseErrorCode.TrailingData, $"The datagram contains {datagram.Length - header.Length} trailing bytes.", header.Length, out error); - pdu = PduRegistry.Parse(header, datagram.Slice(headerSize, header.Length - headerSize)); + ReadOnlySpan body = datagram.Slice(headerSize, header.Length - headerSize); + pdu = header.ProtocolVersion == DisProtocolVersion.Ieee1278_1_2012 + ? PduRegistry.Parse(header, body) + : new UnknownPdu(header, body.ToArray()); return true; } catch (DisParseException exception) @@ -131,4 +250,7 @@ private static bool Fail(DisParseErrorCode code, string message, int offset, out error = new(code, message, offset); return false; } + + private static int RequiredHeaderSize(byte protocolFamily) => + protocolFamily == (byte)ProtocolFamily.LiveEntity ? DisHeader.MinimumSize : DisHeader.Size; } diff --git a/src/OpenDisNet/Pdus/DisPduBuilder.cs b/src/OpenDisNet/Pdus/DisPduBuilder.cs new file mode 100644 index 0000000..c14da67 --- /dev/null +++ b/src/OpenDisNet/Pdus/DisPduBuilder.cs @@ -0,0 +1,134 @@ +using OpenDisNet.Enumerations; + +namespace OpenDisNet.Pdus; + +/// Creates commonly used DIS v7 PDUs with valid discriminators and related fields. +public static class DisPduBuilder +{ + /// Creates an Entity State PDU with its primary identity, type, and position populated. + public static EntityStatePdu CreateEntityState( + EntityId entityId, + EntityType entityType, + Vector3Double location, + ForceId forceId = ForceId.Other, + byte exerciseId = 0) + { + ArgumentNullException.ThrowIfNull(entityId); + ArgumentNullException.ThrowIfNull(entityType); + ArgumentNullException.ThrowIfNull(location); + + return new EntityStatePdu + { + ExerciseId = exerciseId, + EntityId = entityId, + EntityType = entityType, + ForceId = forceId, + EntityLocation = location, + }; + } + + /// Creates a Fire PDU and derives its event simulation address from the firing entity. + public static FirePdu CreateFire( + EntityId firingEntityId, + EntityId targetEntityId, + EntityId munitionEntityId, + ushort eventNumber, + MunitionDescriptor descriptor, + Vector3Double location, + Vector3Float velocity, + float range = 0, + byte exerciseId = 0) + { + ArgumentNullException.ThrowIfNull(firingEntityId); + ArgumentNullException.ThrowIfNull(targetEntityId); + ArgumentNullException.ThrowIfNull(munitionEntityId); + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(location); + ArgumentNullException.ThrowIfNull(velocity); + + return new FirePdu + { + ExerciseId = exerciseId, + FiringEntityId = firingEntityId, + TargetEntityId = targetEntityId, + MunitionExpendibleId = munitionEntityId, + EventId = CreateEventIdentifier(firingEntityId, eventNumber), + Descriptor = descriptor, + LocationInWorldCoordinates = location, + Velocity = velocity, + Range = range, + }; + } + + /// Creates a Detonation PDU and derives its event simulation address from the source entity. + public static DetonationPdu CreateDetonation( + EntityId sourceEntityId, + EntityId targetEntityId, + EntityId explodingEntityId, + ushort eventNumber, + MunitionDescriptor descriptor, + Vector3Double location, + Vector3Float velocity, + DetonationResult result, + byte exerciseId = 0) + { + ArgumentNullException.ThrowIfNull(sourceEntityId); + ArgumentNullException.ThrowIfNull(targetEntityId); + ArgumentNullException.ThrowIfNull(explodingEntityId); + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(location); + ArgumentNullException.ThrowIfNull(velocity); + + return new DetonationPdu + { + ExerciseId = exerciseId, + SourceEntityId = sourceEntityId, + TargetEntityId = targetEntityId, + ExplodingEntityId = explodingEntityId, + EventId = CreateEventIdentifier(sourceEntityId, eventNumber), + Descriptor = descriptor, + LocationInWorldCoordinates = location, + Velocity = velocity, + DetonationResult = result, + }; + } + + /// Creates a Transmitter PDU for an entity radio. + public static TransmitterPdu CreateTransmitter( + RadioId radio, + RadioType radioType, + ulong frequency, + float power, + TransmitterTransmitState transmitState = TransmitterTransmitState.OnAndTransmitting, + TransmitterInputSource inputSource = TransmitterInputSource.Other, + byte exerciseId = 0) + { + ArgumentNullException.ThrowIfNull(radio.Entity); + ArgumentNullException.ThrowIfNull(radioType); + + return new TransmitterPdu + { + ExerciseId = exerciseId, + RadioHeader = new RadioCommsHeader + { + RadioReferenceId = radio.Entity, + RadioNumber = radio.Number, + }, + RadioEntityType = radioType, + Frequency = frequency, + Power = power, + TransmitState = transmitState, + InputSource = inputSource, + }; + } + + private static EventIdentifier CreateEventIdentifier(EntityId entityId, ushort eventNumber) => new() + { + SimulationAddress = new SimulationAddress + { + Site = entityId.SiteId, + Application = entityId.ApplicationId, + }, + EventNumber = eventNumber, + }; +} diff --git a/src/OpenDisNet/PublicAPI.Unshipped.txt b/src/OpenDisNet/PublicAPI.Unshipped.txt index 7dc5c58..2d82a22 100644 --- a/src/OpenDisNet/PublicAPI.Unshipped.txt +++ b/src/OpenDisNet/PublicAPI.Unshipped.txt @@ -1 +1,29 @@ #nullable enable +OpenDisNet.DisReadStatus +OpenDisNet.DisReadStatus.Done = 0 -> OpenDisNet.DisReadStatus +OpenDisNet.DisReadStatus.InvalidData = 2 -> OpenDisNet.DisReadStatus +OpenDisNet.DisReadStatus.NeedMoreData = 1 -> OpenDisNet.DisReadStatus +OpenDisNet.Pdus.DisPduBuilder +OpenDisNet.Validation.DisValidationIssue +OpenDisNet.Validation.DisValidationIssue.Message.get -> string! +OpenDisNet.Validation.DisValidationIssue.Path.get -> string! +OpenDisNet.Validation.DisValidationIssue.Severity.get -> OpenDisNet.Validation.DisValidationSeverity +OpenDisNet.Validation.DisValidationResult +OpenDisNet.Validation.DisValidationResult.HasWarnings.get -> bool +OpenDisNet.Validation.DisValidationResult.Issues.get -> System.Collections.Generic.IReadOnlyList! +OpenDisNet.Validation.DisValidationResult.IsValid.get -> bool +OpenDisNet.Validation.DisValidationSeverity +OpenDisNet.Validation.DisValidationSeverity.Error = 1 -> OpenDisNet.Validation.DisValidationSeverity +OpenDisNet.Validation.DisValidationSeverity.Warning = 0 -> OpenDisNet.Validation.DisValidationSeverity +OpenDisNet.Validation.DisValidator +static OpenDisNet.DisSerializer.TryRead(System.Buffers.ReadOnlySequence source, out OpenDisNet.Pdus.IDisPdu? pdu, out int bytesConsumed, out OpenDisNet.DisParseError error) -> OpenDisNet.DisReadStatus +static OpenDisNet.DisSerializer.TryRead(System.Buffers.ReadOnlySequence source, out OpenDisNet.Pdus.IDisPdu? pdu, out int bytesConsumed, out OpenDisNet.DisParseError error, OpenDisNet.DisParseOptions? options) -> OpenDisNet.DisReadStatus +static OpenDisNet.DisSerializer.TryRead(System.ReadOnlySpan source, out OpenDisNet.Pdus.IDisPdu? pdu, out int bytesConsumed, out OpenDisNet.DisParseError error) -> OpenDisNet.DisReadStatus +static OpenDisNet.DisSerializer.TryRead(System.ReadOnlySpan source, out OpenDisNet.Pdus.IDisPdu? pdu, out int bytesConsumed, out OpenDisNet.DisParseError error, OpenDisNet.DisParseOptions? options) -> OpenDisNet.DisReadStatus +static OpenDisNet.DisSerializer.TryReadHeader(System.Buffers.ReadOnlySequence source, out OpenDisNet.Protocol.DisHeader header, out OpenDisNet.DisParseError error) -> bool +static OpenDisNet.DisSerializer.TryReadHeader(System.ReadOnlySpan source, out OpenDisNet.Protocol.DisHeader header, out OpenDisNet.DisParseError error) -> bool +static OpenDisNet.Pdus.DisPduBuilder.CreateDetonation(OpenDisNet.Pdus.EntityId! sourceEntityId, OpenDisNet.Pdus.EntityId! targetEntityId, OpenDisNet.Pdus.EntityId! explodingEntityId, ushort eventNumber, OpenDisNet.Pdus.MunitionDescriptor! descriptor, OpenDisNet.Pdus.Vector3Double! location, OpenDisNet.Pdus.Vector3Float! velocity, OpenDisNet.Enumerations.DetonationResult result, byte exerciseId = 0) -> OpenDisNet.Pdus.DetonationPdu! +static OpenDisNet.Pdus.DisPduBuilder.CreateEntityState(OpenDisNet.Pdus.EntityId! entityId, OpenDisNet.Pdus.EntityType! entityType, OpenDisNet.Pdus.Vector3Double! location, OpenDisNet.Enumerations.ForceId forceId = OpenDisNet.Enumerations.ForceId.Other, byte exerciseId = 0) -> OpenDisNet.Pdus.EntityStatePdu! +static OpenDisNet.Pdus.DisPduBuilder.CreateFire(OpenDisNet.Pdus.EntityId! firingEntityId, OpenDisNet.Pdus.EntityId! targetEntityId, OpenDisNet.Pdus.EntityId! munitionEntityId, ushort eventNumber, OpenDisNet.Pdus.MunitionDescriptor! descriptor, OpenDisNet.Pdus.Vector3Double! location, OpenDisNet.Pdus.Vector3Float! velocity, float range = 0, byte exerciseId = 0) -> OpenDisNet.Pdus.FirePdu! +static OpenDisNet.Pdus.DisPduBuilder.CreateTransmitter(OpenDisNet.Pdus.RadioId radio, OpenDisNet.Pdus.RadioType! radioType, ulong frequency, float power, OpenDisNet.Enumerations.TransmitterTransmitState transmitState = OpenDisNet.Enumerations.TransmitterTransmitState.OnAndTransmitting, OpenDisNet.Enumerations.TransmitterInputSource inputSource = OpenDisNet.Enumerations.TransmitterInputSource.Other, byte exerciseId = 0) -> OpenDisNet.Pdus.TransmitterPdu! +static OpenDisNet.Validation.DisValidator.Validate(OpenDisNet.Pdus.IDisPdu! pdu) -> OpenDisNet.Validation.DisValidationResult! diff --git a/src/OpenDisNet/Validation/DisValidationIssue.cs b/src/OpenDisNet/Validation/DisValidationIssue.cs new file mode 100644 index 0000000..2438d5d --- /dev/null +++ b/src/OpenDisNet/Validation/DisValidationIssue.cs @@ -0,0 +1,31 @@ +namespace OpenDisNet.Validation; + +/// Severity assigned to a semantic validation issue. +public enum DisValidationSeverity +{ + /// The value is legal on the wire but is likely incomplete or unintended. + Warning, + + /// The value is internally inconsistent or cannot represent a meaningful PDU. + Error, +} + +/// Describes one semantic issue in a decoded or constructed PDU. +public sealed class DisValidationIssue +{ + internal DisValidationIssue(DisValidationSeverity severity, string path, string message) + { + Severity = severity; + Path = path; + Message = message; + } + + /// Gets the severity of the issue. + public DisValidationSeverity Severity { get; } + + /// Gets the property path associated with the issue. + public string Path { get; } + + /// Gets the human-readable explanation. + public string Message { get; } +} diff --git a/src/OpenDisNet/Validation/DisValidationResult.cs b/src/OpenDisNet/Validation/DisValidationResult.cs new file mode 100644 index 0000000..995bb77 --- /dev/null +++ b/src/OpenDisNet/Validation/DisValidationResult.cs @@ -0,0 +1,16 @@ +namespace OpenDisNet.Validation; + +/// Contains the non-mutating semantic validation result for a PDU. +public sealed class DisValidationResult +{ + internal DisValidationResult(IReadOnlyList issues) => Issues = issues; + + /// Gets every issue in deterministic field order. + public IReadOnlyList Issues { get; } + + /// Gets whether validation found no errors. Warnings do not make a result invalid. + public bool IsValid => !Issues.Any(issue => issue.Severity == DisValidationSeverity.Error); + + /// Gets whether validation found at least one warning. + public bool HasWarnings => Issues.Any(issue => issue.Severity == DisValidationSeverity.Warning); +} diff --git a/src/OpenDisNet/Validation/DisValidator.cs b/src/OpenDisNet/Validation/DisValidator.cs new file mode 100644 index 0000000..791a5ed --- /dev/null +++ b/src/OpenDisNet/Validation/DisValidator.cs @@ -0,0 +1,152 @@ +using OpenDisNet.Enumerations; +using OpenDisNet.Pdus; +using OpenDisNet.Protocol; + +namespace OpenDisNet.Validation; + +/// Performs non-mutating semantic checks separately from wire-format parsing. +public static class DisValidator +{ + /// Validates discriminator consistency and common PDU-specific numeric invariants. + public static DisValidationResult Validate(IDisPdu pdu) + { + ArgumentNullException.ThrowIfNull(pdu); + var issues = new List(); + + if (pdu is Pdu typed) + ValidateDiscriminators(typed, issues); + + switch (pdu) + { + case EntityStatePdu entityState: + ValidateVector(entityState.EntityLocation, nameof(EntityStatePdu.EntityLocation), issues); + ValidateVector(entityState.EntityLinearVelocity, nameof(EntityStatePdu.EntityLinearVelocity), issues); + ValidateAngles(entityState.EntityOrientation, nameof(EntityStatePdu.EntityOrientation), issues); + WarnIfUnset(entityState.EntityId, nameof(EntityStatePdu.EntityId), issues); + break; + case FirePdu fire: + ValidateVector(fire.LocationInWorldCoordinates, nameof(FirePdu.LocationInWorldCoordinates), issues); + ValidateVector(fire.Velocity, nameof(FirePdu.Velocity), issues); + ValidateNonNegative(fire.Range, nameof(FirePdu.Range), issues); + WarnIfUnset(fire.FiringEntityId, nameof(FirePdu.FiringEntityId), issues); + break; + case DetonationPdu detonation: + ValidateVector(detonation.LocationInWorldCoordinates, nameof(DetonationPdu.LocationInWorldCoordinates), issues); + ValidateVector(detonation.LocationOfEntityCoordinates, nameof(DetonationPdu.LocationOfEntityCoordinates), issues); + ValidateVector(detonation.Velocity, nameof(DetonationPdu.Velocity), issues); + WarnIfUnset(detonation.SourceEntityId, nameof(DetonationPdu.SourceEntityId), issues); + break; + case TransmitterPdu transmitter: + ValidateNonNegative(transmitter.Power, nameof(TransmitterPdu.Power), issues); + ValidateNonNegative(transmitter.TransmitFrequencyBandwidth, nameof(TransmitterPdu.TransmitFrequencyBandwidth), issues); + if (transmitter.TransmitState == TransmitterTransmitState.OnAndTransmitting && transmitter.Frequency == 0) + AddError(issues, nameof(TransmitterPdu.Frequency), "A transmitting radio must specify a non-zero frequency."); + if (transmitter.RadioHeader is null) + AddError(issues, nameof(TransmitterPdu.RadioHeader), "The radio header is required."); + else + WarnIfUnset(transmitter.RadioHeader.RadioReferenceId, $"{nameof(TransmitterPdu.RadioHeader)}.{nameof(RadioCommsHeader.RadioReferenceId)}", issues); + break; + case SignalPdu signal: + ValidateSignalData(signal, issues); + break; + } + + return new DisValidationResult(issues.ToArray()); + } + + private static void ValidateDiscriminators(Pdu pdu, List issues) + { + if (pdu.ProtocolVersion != DisProtocolVersion.Ieee1278_1_2012) + AddError(issues, nameof(Pdu.ProtocolVersion), "Typed OpenDisNet PDUs use DIS protocol version 7."); + + try + { + Pdu expected = PduFactory.Create(pdu.PduType); + if (expected.GetType() != pdu.GetType()) + AddError(issues, nameof(Pdu.PduType), $"{pdu.GetType().Name} cannot use PDU type {pdu.PduType}."); + if (expected.ProtocolFamily != pdu.ProtocolFamily) + AddError(issues, nameof(Pdu.ProtocolFamily), $"{pdu.GetType().Name} belongs to protocol family {expected.ProtocolFamily}."); + } + catch (ArgumentOutOfRangeException) + { + AddError(issues, nameof(Pdu.PduType), $"PDU type {(byte)pdu.PduType} is not a standardized DIS v7 type."); + } + } + + private static void ValidateSignalData(SignalPdu signal, List issues) + { + if (signal.Data is null) + { + AddError(issues, nameof(SignalPdu.Data), "Signal data is required."); + return; + } + + int maximumBits = checked(signal.Data.Length * 8); + int minimumBits = signal.Data.Length == 0 ? 0 : checked((signal.Data.Length - 1) * 8 + 1); + if (signal.DataBitLength < minimumBits || signal.DataBitLength > maximumBits) + AddError(issues, nameof(SignalPdu.DataBitLength), "The meaningful bit length must describe every supplied octet, allowing only unused bits in the final octet."); + } + + private static void ValidateAngles(EulerAngles? value, string path, List issues) + { + if (value is null) + { + AddError(issues, path, "The value is required."); + return; + } + ValidateFinite(value.Psi, $"{path}.{nameof(EulerAngles.Psi)}", issues); + ValidateFinite(value.Theta, $"{path}.{nameof(EulerAngles.Theta)}", issues); + ValidateFinite(value.Phi, $"{path}.{nameof(EulerAngles.Phi)}", issues); + } + + private static void ValidateVector(Vector3Double? value, string path, List issues) + { + if (value is null) + { + AddError(issues, path, "The value is required."); + return; + } + ValidateFinite(value.X, $"{path}.{nameof(Vector3Double.X)}", issues); + ValidateFinite(value.Y, $"{path}.{nameof(Vector3Double.Y)}", issues); + ValidateFinite(value.Z, $"{path}.{nameof(Vector3Double.Z)}", issues); + } + + private static void ValidateVector(Vector3Float? value, string path, List issues) + { + if (value is null) + { + AddError(issues, path, "The value is required."); + return; + } + ValidateFinite(value.X, $"{path}.{nameof(Vector3Float.X)}", issues); + ValidateFinite(value.Y, $"{path}.{nameof(Vector3Float.Y)}", issues); + ValidateFinite(value.Z, $"{path}.{nameof(Vector3Float.Z)}", issues); + } + + private static void ValidateNonNegative(float value, string path, List issues) + { + ValidateFinite(value, path, issues); + if (float.IsFinite(value) && value < 0) + AddError(issues, path, "The value cannot be negative."); + } + + private static void ValidateFinite(double value, string path, List issues) + { + if (!double.IsFinite(value)) + AddError(issues, path, "The value must be finite."); + } + + private static void WarnIfUnset(EntityId? entityId, string path, List issues) + { + if (entityId is null) + { + AddError(issues, path, "The entity identifier is required."); + return; + } + if (entityId.SiteId == 0 && entityId.ApplicationId == 0 && entityId.EntityNumber == 0) + issues.Add(new(DisValidationSeverity.Warning, path, "The entity identifier is unset.")); + } + + private static void AddError(List issues, string path, string message) => + issues.Add(new(DisValidationSeverity.Error, path, message)); +} diff --git a/tests/OpenDisNet.Fuzz/OpenDisNet.Fuzz.csproj b/tests/OpenDisNet.Fuzz/OpenDisNet.Fuzz.csproj new file mode 100644 index 0000000..c61699f --- /dev/null +++ b/tests/OpenDisNet.Fuzz/OpenDisNet.Fuzz.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0 + false + + + + + + + + diff --git a/tests/OpenDisNet.Fuzz/ParserFuzzTarget.cs b/tests/OpenDisNet.Fuzz/ParserFuzzTarget.cs new file mode 100644 index 0000000..759e7e3 --- /dev/null +++ b/tests/OpenDisNet.Fuzz/ParserFuzzTarget.cs @@ -0,0 +1,109 @@ +using System.Buffers; +using OpenDisNet; +using OpenDisNet.Pdus; +using OpenDisNet.Protocol; +using OpenDisNet.Validation; + +internal static class ParserFuzzTarget +{ + private static readonly DisParseOptions PermissiveOptions = new() + { + RequireVersion7 = false, + MaximumPduLength = ushort.MaxValue, + }; + + public static void Run(ReadOnlySpan input) + { + DisSerializer.TryReadHeader(input, out _, out _); + DisSerializer.TryDeserialize(input, out _, out _, PermissiveOptions); + + DisReadStatus status = DisSerializer.TryRead(input, out IDisPdu? pdu, out int consumed, out _, PermissiveOptions); + if (status != DisReadStatus.Done) + return; + + if (pdu is null || consumed <= 0 || consumed > input.Length) + throw new InvalidOperationException("A successful framed read returned an invalid result."); + + _ = DisValidator.Validate(pdu); + byte[] serialized = DisSerializer.Serialize(pdu); + if (DisSerializer.TryRead(serialized, out _, out int roundTripConsumed, out _, PermissiveOptions) != DisReadStatus.Done || + roundTripConsumed != serialized.Length) + { + throw new InvalidOperationException("A parsed PDU did not survive serialization and framed parsing."); + } + + var segmented = CreateSegmentedSequence(input[..consumed]); + if (DisSerializer.TryRead(segmented, out _, out int segmentedConsumed, out _, PermissiveOptions) != DisReadStatus.Done || + segmentedConsumed != consumed) + { + throw new InvalidOperationException("Contiguous and segmented parsing produced different framing results."); + } + } + + public static void RunSmokeCorpus() + { + IReadOnlyList seeds = CreateSeeds(); + foreach (byte[] seed in seeds) + { + Run(seed); + for (int length = 0; length < seed.Length; length += Math.Max(1, seed.Length / 8)) + Run(seed.AsSpan(0, length)); + + for (int index = 0; index < seed.Length; index += Math.Max(1, seed.Length / 16)) + { + byte original = seed[index]; + seed[index] ^= 0xFF; + Run(seed); + seed[index] = original; + } + } + + var random = new Random(1278); + for (int iteration = 0; iteration < 1_000; iteration++) + { + byte[] bytes = new byte[random.Next(0, 2048)]; + random.NextBytes(bytes); + Run(bytes); + } + } + + public static void WriteCorpus(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + Directory.CreateDirectory(directory); + IReadOnlyList seeds = CreateSeeds(); + for (int index = 0; index < seeds.Count; index++) + File.WriteAllBytes(Path.Combine(directory, $"pdu-{index:D2}.bin"), seeds[index]); + } + + private static IReadOnlyList CreateSeeds() + { + var seeds = new List { Array.Empty(), new byte[] { 7, 1, 1 } }; + for (byte pduType = 1; pduType <= 72; pduType++) + seeds.Add(DisSerializer.Serialize(PduFactory.Create((PduType)pduType, exerciseId: 1))); + + var nonV7Header = new DisHeader((DisProtocolVersion)6, 1, PduType.EntityState, ProtocolFamily.EntityInformationInteraction, 0, 16, 0, 0); + seeds.Add(DisSerializer.Serialize(new UnknownPdu(nonV7Header, new byte[4]))); + return seeds; + } + + private static ReadOnlySequence CreateSegmentedSequence(ReadOnlySpan input) + { + int split = input.Length / 2; + var first = new BufferSegment(input[..split].ToArray()); + BufferSegment last = first.Append(input[split..].ToArray()); + return new ReadOnlySequence(first, 0, last, last.Memory.Length); + } + + private sealed class BufferSegment : ReadOnlySequenceSegment + { + public BufferSegment(ReadOnlyMemory memory) => Memory = memory; + + public BufferSegment Append(ReadOnlyMemory memory) + { + var segment = new BufferSegment(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = segment; + return segment; + } + } +} diff --git a/tests/OpenDisNet.Fuzz/Program.cs b/tests/OpenDisNet.Fuzz/Program.cs new file mode 100644 index 0000000..1cbcb51 --- /dev/null +++ b/tests/OpenDisNet.Fuzz/Program.cs @@ -0,0 +1,20 @@ +using SharpFuzz; + +if (args is ["--smoke"]) +{ + ParserFuzzTarget.RunSmokeCorpus(); + return; +} + +if (args is ["--write-corpus", string directory]) +{ + ParserFuzzTarget.WriteCorpus(directory); + return; +} + +Fuzzer.OutOfProcess.Run(stream => +{ + using var input = new MemoryStream(); + stream.CopyTo(input); + ParserFuzzTarget.Run(input.ToArray()); +}); diff --git a/tests/OpenDisNet.Fuzz/packages.lock.json b/tests/OpenDisNet.Fuzz/packages.lock.json new file mode 100644 index 0000000..650eea7 --- /dev/null +++ b/tests/OpenDisNet.Fuzz/packages.lock.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "SharpFuzz": { + "type": "Direct", + "requested": "[2.3.0, )", + "resolved": "2.3.0", + "contentHash": "5f11toR82RVCIBTr5XuuauJgesGVXIAO6vBCvLThNbrcer9NN6qB2HCA/Q122BvH2qRPsLx14O6QVtCbgRQhog==", + "dependencies": { + "SharpFuzz.Common": "2.2.0", + "dnlib": "4.4.0" + } + }, + "dnlib": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "cKHI720q+zfEEvzklWVGt6B0TH3AibAyJbpUJl4U6KvTP13tycfnqJpkGHRZ/oQ45BTIoIxIwltHIJVDN+iCqQ==" + }, + "SharpFuzz.Common": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "biITWpwnMR7HUp43lAGU97DWq/4LfyXqqhuOK0Z4IuRP97KjQMOe/GKq3wE1KY21gNrc7OPO9HbAtQUvMKTImA==" + }, + "opendisnet": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/OpenDisNet.PackageSmoke/Program.cs b/tests/OpenDisNet.PackageSmoke/Program.cs index bd101e4..5d0f865 100644 --- a/tests/OpenDisNet.PackageSmoke/Program.cs +++ b/tests/OpenDisNet.PackageSmoke/Program.cs @@ -2,6 +2,7 @@ using OpenDisNet.Enumerations; using OpenDisNet.Pdus; using OpenDisNet.Protocol; +using OpenDisNet.Validation; var signal = new SignalPdu { @@ -21,6 +22,23 @@ if (!decoded.Data.AsSpan().SequenceEqual("external-consumer"u8)) throw new InvalidOperationException("The packed Signal PDU API did not round-trip its payload."); +if (DisSerializer.TryRead(datagram, out IDisPdu? framed, out int consumed, out _) != DisReadStatus.Done || + framed is not SignalPdu || consumed != datagram.Length) +{ + throw new InvalidOperationException("The packed framed-reading API did not consume the Signal PDU."); +} + +FirePdu fire = DisPduBuilder.CreateFire( + new EntityId(1, 2, 3), + new EntityId(1, 2, 4), + new EntityId(1, 2, 5), + 1, + new MunitionDescriptor { Quantity = 1 }, + new Vector3Double(), + new Vector3Float()); +if (!DisValidator.Validate(fire).IsValid) + throw new InvalidOperationException("The packed builder and validation APIs produced an invalid Fire PDU."); + foreach (PduType type in Enum.GetValues().Where(x => (byte)x is >= 1 and <= 72)) { Pdu pdu = PduFactory.Create(type, exerciseId: 7); diff --git a/tests/OpenDisNet.Tests/FramedReadingTests.cs b/tests/OpenDisNet.Tests/FramedReadingTests.cs new file mode 100644 index 0000000..309b794 --- /dev/null +++ b/tests/OpenDisNet.Tests/FramedReadingTests.cs @@ -0,0 +1,118 @@ +using System.Buffers; +using OpenDisNet.Pdus; +using OpenDisNet.Protocol; + +namespace OpenDisNet.Tests; + +[TestClass] +public sealed class FramedReadingTests +{ + [TestMethod] + public void PermissiveVersionParsingPreservesNonVersion7Body() + { + byte[] body = [0xAA, 0xBB, 0xCC, 0xDD]; + var header = new DisHeader( + (DisProtocolVersion)6, + 1, + PduType.EntityState, + ProtocolFamily.EntityInformationInteraction, + 42, + 0, + 0, + 0); + byte[] datagram = DisSerializer.Serialize(new UnknownPdu(header, body)); + + Assert.IsFalse(DisSerializer.TryDeserialize(datagram, out _, out DisParseError strictError)); + Assert.AreEqual(DisParseErrorCode.UnsupportedProtocolVersion, strictError.Code); + + var options = new DisParseOptions { RequireVersion7 = false }; + Assert.IsTrue(DisSerializer.TryDeserialize(datagram, out IDisPdu? parsed, out _, options)); + UnknownPdu unknown = Assert.IsInstanceOfType(parsed); + Assert.AreEqual((byte)6, (byte)unknown.Header.ProtocolVersion); + Assert.AreSequenceEqual(body, unknown.Body.ToArray()); + } + + [TestMethod] + public void HeaderInspectionDoesNotRequireThePduBody() + { + byte[] datagram = DisSerializer.Serialize(new FirePdu()); + + Assert.IsTrue(DisSerializer.TryReadHeader(datagram.AsSpan(0, DisHeader.Size), out DisHeader header, out _)); + Assert.AreEqual(PduType.Fire, header.PduType); + Assert.AreEqual(datagram.Length, header.Length); + } + + [TestMethod] + public void FramedSpanReadingConsumesOnePduAtATime() + { + byte[] first = DisSerializer.Serialize(new FirePdu()); + byte[] second = DisSerializer.Serialize(new EntityStatePdu()); + byte[] combined = [.. first, .. second]; + + Assert.AreEqual(DisReadStatus.Done, DisSerializer.TryRead(combined, out IDisPdu? firstPdu, out int firstConsumed, out _)); + Assert.IsInstanceOfType(firstPdu); + Assert.AreEqual(first.Length, firstConsumed); + + Assert.AreEqual(DisReadStatus.Done, DisSerializer.TryRead(combined.AsSpan(firstConsumed), out IDisPdu? secondPdu, out int secondConsumed, out _)); + Assert.IsInstanceOfType(secondPdu); + Assert.AreEqual(second.Length, secondConsumed); + } + + [TestMethod] + public void SegmentedReadingMatchesContiguousReading() + { + byte[] datagram = DisSerializer.Serialize(new EntityStatePdu()); + ReadOnlySequence sequence = CreateSequence(datagram, split: 5); + + Assert.IsTrue(DisSerializer.TryReadHeader(sequence, out DisHeader header, out _)); + Assert.AreEqual(PduType.EntityState, header.PduType); + Assert.AreEqual(DisReadStatus.Done, DisSerializer.TryRead(sequence, out IDisPdu? pdu, out int consumed, out _)); + Assert.IsInstanceOfType(pdu); + Assert.AreEqual(datagram.Length, consumed); + } + + [TestMethod] + public void FramedReadingDistinguishesIncompleteAndInvalidInput() + { + byte[] datagram = DisSerializer.Serialize(new FirePdu()); + Assert.AreEqual(DisReadStatus.NeedMoreData, DisSerializer.TryRead(datagram.AsSpan(0, datagram.Length - 1), out _, out int incompleteConsumed, out DisParseError incompleteError)); + Assert.AreEqual(0, incompleteConsumed); + Assert.AreEqual(DisParseErrorCode.TruncatedPdu, incompleteError.Code); + + byte[] invalid = (byte[])datagram.Clone(); + invalid[8] = 0; + invalid[9] = 1; + Assert.AreEqual(DisReadStatus.InvalidData, DisSerializer.TryRead(invalid, out _, out int invalidConsumed, out DisParseError invalidError)); + Assert.AreEqual(0, invalidConsumed); + Assert.AreEqual(DisParseErrorCode.InvalidLength, invalidError.Code); + } + + [TestMethod] + public void FramedReadingHonorsMaximumPduLength() + { + byte[] datagram = DisSerializer.Serialize(new EntityStatePdu()); + var options = new DisParseOptions { MaximumPduLength = datagram.Length - 1 }; + + Assert.AreEqual(DisReadStatus.InvalidData, DisSerializer.TryRead(datagram, out _, out _, out DisParseError error, options)); + Assert.AreEqual(DisParseErrorCode.InvalidLength, error.Code); + } + + private static ReadOnlySequence CreateSequence(byte[] bytes, int split) + { + var first = new Segment(bytes.AsMemory(0, split)); + Segment last = first.Append(bytes.AsMemory(split)); + return new ReadOnlySequence(first, 0, last, last.Memory.Length); + } + + private sealed class Segment : ReadOnlySequenceSegment + { + public Segment(ReadOnlyMemory memory) => Memory = memory; + + public Segment Append(ReadOnlyMemory memory) + { + var segment = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length }; + Next = segment; + return segment; + } + } +} diff --git a/tests/OpenDisNet.Tests/Pdus/DisPduBuilderTests.cs b/tests/OpenDisNet.Tests/Pdus/DisPduBuilderTests.cs new file mode 100644 index 0000000..a4f5781 --- /dev/null +++ b/tests/OpenDisNet.Tests/Pdus/DisPduBuilderTests.cs @@ -0,0 +1,58 @@ +using OpenDisNet.Enumerations; +using OpenDisNet.Pdus; + +namespace OpenDisNet.Tests.Pdus; + +[TestClass] +public sealed class DisPduBuilderTests +{ + [TestMethod] + public void EntityStateBuilderCreatesRoundTrippablePdu() + { + EntityStatePdu pdu = DisPduBuilder.CreateEntityState( + new EntityId(1, 2, 3), + new EntityType { EntityKind = EntityKind.Platform }, + new Vector3Double { X = 10, Y = 20, Z = 30 }, + ForceId.Friendly, + exerciseId: 4); + + EntityStatePdu parsed = DisSerializer.Deserialize(DisSerializer.Serialize(pdu)); + Assert.AreEqual((byte)4, parsed.ExerciseId); + Assert.AreEqual((ushort)3, parsed.EntityId.EntityNumber); + Assert.AreEqual(30, parsed.EntityLocation.Z); + } + + [TestMethod] + public void FireAndDetonationBuildersDeriveEventAddress() + { + var source = new EntityId(10, 20, 30); + var target = new EntityId(10, 20, 31); + var munition = new EntityId(10, 20, 32); + var descriptor = new MunitionDescriptor { Quantity = 1 }; + var location = new Vector3Double { X = 100, Y = 200, Z = 300 }; + var velocity = new Vector3Float { X = 1, Y = 2, Z = 3 }; + + FirePdu fire = DisPduBuilder.CreateFire(source, target, munition, 99, descriptor, location, velocity, range: 500); + DetonationPdu detonation = DisPduBuilder.CreateDetonation(source, target, munition, 99, descriptor, location, velocity, DetonationResult.EntityImpact); + + Assert.AreEqual((ushort)10, fire.EventId.SimulationAddress.Site); + Assert.AreEqual((ushort)20, fire.EventId.SimulationAddress.Application); + Assert.AreEqual((ushort)99, detonation.EventId.EventNumber); + Assert.IsInstanceOfType(DisSerializer.Deserialize(DisSerializer.Serialize(fire))); + Assert.IsInstanceOfType(DisSerializer.Deserialize(DisSerializer.Serialize(detonation))); + } + + [TestMethod] + public void TransmitterBuilderPopulatesRadioAndOperatingState() + { + TransmitterPdu transmitter = DisPduBuilder.CreateTransmitter( + new RadioId(new EntityId(1, 2, 3), 7), + new RadioType(), + frequency: 225_000_000, + power: 50); + + Assert.AreEqual((ushort)7, transmitter.RadioHeader.RadioNumber); + Assert.AreEqual(TransmitterTransmitState.OnAndTransmitting, transmitter.TransmitState); + Assert.IsInstanceOfType(DisSerializer.Deserialize(DisSerializer.Serialize(transmitter))); + } +} diff --git a/tests/OpenDisNet.Tests/Validation/DisValidatorTests.cs b/tests/OpenDisNet.Tests/Validation/DisValidatorTests.cs new file mode 100644 index 0000000..53e85af --- /dev/null +++ b/tests/OpenDisNet.Tests/Validation/DisValidatorTests.cs @@ -0,0 +1,74 @@ +using OpenDisNet.Enumerations; +using OpenDisNet.Pdus; +using OpenDisNet.Protocol; +using OpenDisNet.Validation; + +namespace OpenDisNet.Tests.Validation; + +[TestClass] +public sealed class DisValidatorTests +{ + [TestMethod] + public void BuilderOutputPassesSemanticValidation() + { + EntityStatePdu pdu = DisPduBuilder.CreateEntityState( + new EntityId(1, 2, 3), + new EntityType { EntityKind = EntityKind.Platform }, + new Vector3Double { X = 1, Y = 2, Z = 3 }); + + DisValidationResult result = DisValidator.Validate(pdu); + Assert.IsTrue(result.IsValid); + Assert.IsFalse(result.HasWarnings); + Assert.HasCount(0, result.Issues); + } + + [TestMethod] + public void ValidatorReportsNumericAndDiscriminatorErrors() + { + var fire = new FirePdu + { + FiringEntityId = new EntityId(1, 2, 3), + Range = -1, + Velocity = new Vector3Float { X = float.NaN }, + ProtocolFamily = ProtocolFamily.RadioCommunications, + }; + + DisValidationResult result = DisValidator.Validate(fire); + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Issues.Any(issue => issue.Path == nameof(FirePdu.Range))); + Assert.IsTrue(result.Issues.Any(issue => issue.Path == $"{nameof(FirePdu.Velocity)}.{nameof(Vector3Float.X)}")); + Assert.IsTrue(result.Issues.Any(issue => issue.Path == nameof(Pdu.ProtocolFamily))); + } + + [TestMethod] + public void ValidatorReportsIncompleteTransmitterAndWarnings() + { + var transmitter = new TransmitterPdu + { + TransmitState = TransmitterTransmitState.OnAndTransmitting, + Power = 1, + }; + + DisValidationResult result = DisValidator.Validate(transmitter); + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.HasWarnings); + Assert.IsTrue(result.Issues.Any(issue => issue.Path == nameof(TransmitterPdu.Frequency))); + Assert.IsTrue(result.Issues.Any(issue => issue.Severity == DisValidationSeverity.Warning)); + } + + [TestMethod] + public void ValidatorReportsNullRequiredModelsWithoutThrowing() + { + var entity = new EntityStatePdu + { + EntityId = null!, + EntityLocation = null!, + EntityOrientation = null!, + }; + + DisValidationResult result = DisValidator.Validate(entity); + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Issues.Any(issue => issue.Path == nameof(EntityStatePdu.EntityId))); + Assert.IsTrue(result.Issues.Any(issue => issue.Path == nameof(EntityStatePdu.EntityLocation))); + } +}