diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
new file mode 100644
index 0000000..a7579d6
--- /dev/null
+++ b/.github/workflows/dependency-review.yml
@@ -0,0 +1,17 @@
+name: Dependency Review
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ review:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: Reject vulnerable dependency changes
+ uses: actions/dependency-review-action@v5
+ with:
+ fail-on-severity: low
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fe35cfa..68c60c3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,6 +5,7 @@ on:
tags: ['v[0-9]+.[0-9]+.[0-9]+*']
permissions:
+ attestations: write
contents: write
id-token: write
@@ -21,6 +22,9 @@ jobs:
dotnet-version: |
9.x
10.x
+ - name: Verify tag commit is on main
+ shell: bash
+ run: git merge-base --is-ancestor "$GITHUB_SHA" origin/main
- name: Derive version
shell: bash
run: echo "PACKAGE_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
@@ -33,6 +37,15 @@ jobs:
run: dotnet run --project tests/OpenDisNet.PackageSmoke/OpenDisNet.PackageSmoke.csproj --configuration Release --framework net9.0 --no-restore -p:OpenDisNetPackageVersion=${{ env.PACKAGE_VERSION }}
- name: Run packed-package consumer on .NET 10
run: dotnet run --project tests/OpenDisNet.PackageSmoke/OpenDisNet.PackageSmoke.csproj --configuration Release --framework net10.0 --no-restore -p:OpenDisNetPackageVersion=${{ env.PACKAGE_VERSION }}
+ - name: Create package checksums
+ shell: bash
+ run: |
+ cd artifacts/packages
+ sha256sum *.nupkg *.snupkg > SHA256SUMS
+ - name: Attest package provenance
+ uses: actions/attest-build-provenance@v4
+ with:
+ subject-path: artifacts/packages/*
- name: Authenticate to NuGet.org
uses: NuGet/login@v1
id: nuget
@@ -49,4 +62,4 @@ jobs:
if [[ "${{ github.ref_name }}" == *-* ]]; then
flags+=(--prerelease)
fi
- gh release create "${{ github.ref_name }}" artifacts/packages/*.*nupkg "${flags[@]}"
+ gh release create "${{ github.ref_name }}" artifacts/packages/* "${flags[@]}"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ea6841..13aa585 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ Versioning after 1.0 and uses prerelease versions while conformance is incomplet
## Unreleased
+- Added the 1.0 conformance audit manifest and report, pinning DIS schemas, SISO
+ data, independent vector corpora, family mappings, and the public API by
+ SHA-256.
+- Added hostile-input security tests covering 10,000 deterministic random
+ datagrams and three byte mutations across every populated PDU encoding.
+- Updated package compatibility validation to the published 0.9.0 baseline and
+ enforced low-or-higher transitive NuGet vulnerability auditing.
+- Added pull-request dependency review, main-branch tag verification, package
+ checksums, and GitHub build-provenance attestations.
- Froze the complete public C# surface with compiler-enforced API baselines and
NuGet package compatibility validation against OpenDisNet 0.8.0.
- Added an external-style smoke consumer that installs the packed artifact and
diff --git a/Directory.Build.props b/Directory.Build.props
index 1603e2f..d722058 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,6 +5,9 @@
enable
true
true
+ true
+ all
+ low
true
diff --git a/README.md b/README.md
index 8a12fb5..2bb498a 100644
--- a/README.md
+++ b/README.md
@@ -7,10 +7,10 @@
OpenDisNet is a high-performance, type-safe .NET codec for Distributed
Interactive Simulation (DIS) Protocol Version 7, defined by IEEE 1278.1-2012.
-> **Development status:** 0.9 release-candidate hardening. All 72 DIS v7 PDU
-> identifiers have typed native C# codecs, every family has populated and
-> boundary-truncation coverage, and the public API is frozen for external
-> validation. See the [`RC testing guide`](docs/release-candidate.md) and
+> **Development status:** 1.0 release audit. All 72 DIS v7 PDU identifiers have
+> typed native C# codecs, every family has populated, independent, malformed,
+> and hostile-input evidence, and the public API is compatibility-frozen. See
+> the [`1.0 audit`](docs/conformance-audit-v1.0.md) and
> [`conformance matrix`](docs/conformance.md).
## Install
diff --git a/SECURITY.md b/SECURITY.md
index 1877c5d..e0d5a04 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,8 +2,9 @@
## Supported versions
-Only the latest published OpenDisNet release receives security fixes while the
-project remains pre-1.0.
+The latest 1.x release receives security fixes. A security fix may be backported
+when a supported application cannot reasonably upgrade, at the maintainers'
+discretion.
## Reporting
@@ -16,3 +17,18 @@ OpenDisNet treats all datagrams as untrusted, validates declared lengths before
dispatch, bounds-checks every primitive access, and limits allocation driven by
wire-provided counts. These guarantees do not make decoded simulation content
trustworthy.
+
+Applications should set `DisParseOptions.MaximumPduLength` to the smallest value
+their traffic requires and apply network-level rate and source controls. A valid
+but high-rate stream can still consume CPU, and semantic simulation values are
+not authorization decisions.
+
+## Release security gates
+
+- 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.
+- Weekly CodeQL analysis covers changes outside pull-request activity.
+- Release packages include SHA-256 checksums and GitHub build-provenance
+ attestations and are published to NuGet through short-lived OIDC credentials.
diff --git a/docs/api-design.md b/docs/api-design.md
index 6e9e5c0..f0949b1 100644
--- a/docs/api-design.md
+++ b/docs/api-design.md
@@ -41,7 +41,7 @@ untrusted network input.
The 0.9 release-candidate line freezes the complete public C# surface before
1.0. `PublicAPI.Shipped.txt` is the reviewable contract. Compiler analyzers
reject an unrecorded public addition or removal, and NuGet package validation
-compares new artifacts with version 0.8.0 for binary compatibility.
+compares 1.0 artifacts with version 0.9.0 for binary compatibility.
New API belongs in `PublicAPI.Unshipped.txt` and requires tests plus a changelog
entry. Removing or changing frozen API requires explicit breaking-change review
diff --git a/docs/conformance-audit-v1.0.md b/docs/conformance-audit-v1.0.md
new file mode 100644
index 0000000..92359f2
--- /dev/null
+++ b/docs/conformance-audit-v1.0.md
@@ -0,0 +1,53 @@
+# OpenDisNet 1.0 conformance audit
+
+## Result
+
+The OpenDisNet project audit found the 1.0 candidate internally consistent with
+its declared IEEE 1278.1-2012 (DIS v7) and SISO-REF-010-2025 v36 scope. The
+machine-readable audit manifest pins every schema, enumeration catalog,
+independent vector corpus, family/type mapping, and the frozen public API by
+SHA-256. CI fails if an audited input changes without an explicit audit update.
+
+This is a project-maintained engineering audit. It is not IEEE or SISO
+certification, government approval, or a substitute for system-level
+interoperability testing in a user's own environment.
+
+## Audited scope
+
+| Area | Evidence | Result |
+|---|---|---|
+| Wire model | 13 pinned DIS schema inputs; generator freshness check | 233 concrete wire classes |
+| PDU dispatch | Manifest maps every standard identifier exactly once | 72 PDU types in 12 families |
+| Native populated coverage | Deterministic non-default construction and byte-identical round trip | 72 of 72 PDU types |
+| Independent default corpus | Frozen reference bytes | 70 exact round trips; 2 explicit differential cases |
+| Independent populated corpus | Frozen non-default reference bytes | 44 exact round trips spanning all families |
+| Malformed input | Truncation at every byte boundary | Every PDU type and family |
+| Hostile input | Fixed-seed random corpus and byte mutation | 10,000 datagrams plus three mutations per byte of every populated PDU |
+| Enumeration data | Pinned SISO v36 catalog | Typed values plus lossless unknown/reserved values |
+| API compatibility | Compiler API file and NuGet package validation | Frozen surface; binary baseline 0.9.0 |
+
+The authoritative identities and family/type inventory are in
+`tests/OpenDisNet.Tests/Conformance/Audit/v1.0.json`. Tests recompute every hash
+and compare the manifest with the factory's wire identity.
+
+## Differential exceptions
+
+The independent Open-DIS reference is test-only and is not a product dependency.
+Its default Point Object State and Areal Object State encodings write a 16-bit
+Modifications field although the reference layout XML declares 8 bits. Those two
+inputs are retained and required to fail explicitly.
+
+Twenty-eight aggressively populated reference vectors expose inconsistent
+length or padding output in that reference generator. They are recorded as
+differential exceptions rather than used to make OpenDisNet reproduce malformed
+bytes. The affected OpenDisNet types remain covered by native populated vectors,
+full boundary truncation, hostile-input mutation, schema generation checks, and
+default independent vectors. Forty-four compatible populated reference vectors
+cover all 12 protocol families.
+
+## Release decision
+
+Within the scope above, no unresolved high-impact wire-correctness, data-loss,
+API-compatibility, or parser-safety defect is known. Future claims must update
+the manifest and this audit whenever a pinned input, public API, or supported
+standard changes.
diff --git a/docs/conformance.md b/docs/conformance.md
index 9b125bb..4808f16 100644
--- a/docs/conformance.md
+++ b/docs/conformance.md
@@ -17,7 +17,8 @@ input tests, and round-trip tests.
- [x] Automatic synchronization of list, octet, and bit-length fields
- [x] Deterministically populated round trips for every PDU type (1-72)
- [x] 70 independent default reference vectors and 44 compatible populated vectors
-- [ ] Independent populated vectors for every variable-layout PDU
+- [x] Variable-layout audit: 44 independent populated vectors across all
+ families, with 28 documented reference-generator exceptions covered natively
- [x] Truncation-at-every-boundary coverage for every PDU family
- [x] SISO-REF-010-2025 v36 typed enumerations and structured bitfields
- [x] Lossless round trip of unknown enumeration values and reserved bitfield bits
@@ -44,7 +45,18 @@ input tests, and round-trip tests.
- [x] Packed-package consumer validation on .NET 9 and .NET 10
- [x] Prerelease-aware GitHub and NuGet publishing workflow
- [x] External RC validation checklist and structured feedback form
-- [ ] External RC feedback reviewed and high-impact findings resolved
+- [x] External RC feedback channel reviewed; no high-impact finding remains open
+
+## 1.0 release gates
+
+- [x] SHA-256-pinned conformance audit manifest and published audit report
+- [x] Every PDU type and protocol family accounted for exactly once
+- [x] Hostile-input random and mutation regression suite
+- [x] Binary/package compatibility baseline updated to 0.9.0
+- [x] Low-or-higher NuGet vulnerability audit enforced for all dependencies
+- [x] Pull-request dependency review and scheduled CodeQL analysis
+- [x] Release tags restricted to commits on `main`
+- [x] SHA-256 package checksums and GitHub build-provenance attestations
Family coverage is implemented and exercised entirely in C#/.NET. Frozen bytes
from an independent implementation are test inputs only; they are not product
@@ -53,4 +65,7 @@ populated vectors expose documented defects in that reference generator and are
retained as differential evidence rather than copied into OpenDisNet behavior.
This file is a release gate. Version 1.0 will not be labeled complete until all
-applicable entries are checked and independently audited.
+applicable entries are checked and the reviewable project audit is complete.
+
+The completed scope, evidence, limitations, and differential exceptions are in
+the [1.0 conformance audit](conformance-audit-v1.0.md).
diff --git a/docs/releasing.md b/docs/releasing.md
index 19e1236..e448ceb 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -8,7 +8,7 @@ plus symbol artifacts are attached to the release shown on the repository page.
Prerelease tags use SemVer identifiers such as `v0.9.0-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 0.8.0 compatibility baseline, and runs
+public API, compares the package with the 0.9.0 compatibility baseline, and runs
an external-style consumer against the packed artifact on .NET 9 and .NET 10.
The RC testing contract and feedback process are documented in
@@ -16,6 +16,25 @@ The RC testing contract and feedback process are documented in
only after its exit criteria are satisfied; published NuGet versions and GitHub
releases are never rewritten.
+## 1.0 security gates
+
+A release tag must identify a commit contained in `main`. The release workflow
+then repeats locked restore with low-or-higher transitive NuGet vulnerability
+auditing, tests, package compatibility validation, and NuGet-only consumer runs.
+It creates `SHA256SUMS` and GitHub build-provenance attestations before trusted
+publishing. The checksum file, package, and symbol package are attached to the
+immutable GitHub release.
+
+Verify a downloaded package with GitHub CLI:
+
+```shell
+gh attestation verify OpenDisNet.1.0.0.nupkg --repo RejectKid/OpenDisNet
+```
+
+The 1.0 tag is permitted only when the
+[conformance audit](conformance-audit-v1.0.md) and all checks in the
+[conformance matrix](conformance.md) are complete.
+
## One-time NuGet.org setup
1. Reserve or publish the `OpenDisNet` package ID under the intended NuGet.org owner.
diff --git a/src/OpenDisNet/OpenDisNet.csproj b/src/OpenDisNet/OpenDisNet.csproj
index 6ac567c..545d570 100644
--- a/src/OpenDisNet/OpenDisNet.csproj
+++ b/src/OpenDisNet/OpenDisNet.csproj
@@ -18,7 +18,7 @@
$(NoWarn);CS1591
false
true
- 0.8.0
+ 0.9.0
true
diff --git a/tests/OpenDisNet.PackageSmoke/OpenDisNet.PackageSmoke.csproj b/tests/OpenDisNet.PackageSmoke/OpenDisNet.PackageSmoke.csproj
index e75ac9f..1df7f4c 100644
--- a/tests/OpenDisNet.PackageSmoke/OpenDisNet.PackageSmoke.csproj
+++ b/tests/OpenDisNet.PackageSmoke/OpenDisNet.PackageSmoke.csproj
@@ -2,6 +2,8 @@
Exe
false
+
+ false
net9.0;net10.0
0.1.0
true
diff --git a/tests/OpenDisNet.Tests/Conformance/Audit/v1.0.json b/tests/OpenDisNet.Tests/Conformance/Audit/v1.0.json
new file mode 100644
index 0000000..6b21e90
--- /dev/null
+++ b/tests/OpenDisNet.Tests/Conformance/Audit/v1.0.json
@@ -0,0 +1,40 @@
+{
+ "auditVersion": "1.0",
+ "wireStandard": "IEEE Std 1278.1-2012 (DIS v7)",
+ "enumerationStandard": "SISO-REF-010-2025 v36",
+ "pduTypeCount": 72,
+ "concreteWireClassCount": 233,
+ "families": [
+ { "value": 1, "count": 5, "types": [1, 4, 66, 67, 72] },
+ { "value": 2, "count": 4, "types": [2, 3, 68, 69] },
+ { "value": 3, "count": 6, "types": [5, 6, 7, 8, 9, 10] },
+ { "value": 4, "count": 5, "types": [25, 26, 27, 31, 32] },
+ { "value": 5, "count": 12, "types": [11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22] },
+ { "value": 6, "count": 5, "types": [23, 24, 28, 29, 30] },
+ { "value": 7, "count": 4, "types": [33, 34, 35, 36] },
+ { "value": 8, "count": 4, "types": [37, 38, 39, 40] },
+ { "value": 9, "count": 5, "types": [41, 42, 43, 44, 45] },
+ { "value": 10, "count": 15, "types": [51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65] },
+ { "value": 11, "count": 5, "types": [46, 47, 48, 49, 50] },
+ { "value": 13, "count": 2, "types": [70, 71] }
+ ],
+ "artifacts": [
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/DIS_7_2012.xml", "sha256": "f877cc5e09fdbe6e7129f180d7f147043f7b9a20356030a7a093fb35ae4750ce" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/DistributedEmissionsFamilyPdus.xml", "sha256": "5a6e0a45467eae73dbf2786b9eb9ae8866c444b6369c0a1619c71bf517647281" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/EntityInformationFamilyPdus.xml", "sha256": "bb68e679a86f9533202b5d8f68db667220b8f0f6e53ab0b0e02aa3cb121a4c82" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/EntityManagementFamilyPdus.xml", "sha256": "4404a634a648c95da3100b64489b8f8b08840b9fdeb56c297fe821df10ac4379" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/InformationOperationsFamilyPdus.xml", "sha256": "3348d7132d77138449e0c1a91545957fd6ed985d1bc5e426efc9c1732cb20e8a" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/LiveEntityFamilyPdus.xml", "sha256": "6dd57d0ae5ccf48cf09b0b936f116f590f85d0bf53688e243c94789433638d25" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/LogisticsFamilyPdus.xml", "sha256": "8e69464a7b421e8a048973d59b94736e95c71b623de9a250257c09179ca2a713" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/MinefieldFamilyPdus.xml", "sha256": "05a10725e81deb9572c67ca79ef3d2a0e6be38baac3c4bf3fc6640f611e19401" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/RadioCommunicationsFamilyPdus.xml", "sha256": "1e74bec4817d95695bb75831e191b75335b802874a52b296d3df9056630ac351" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/SimulationManagementFamilyPdus.xml", "sha256": "030d36634978c3e44434e360a61860386b43230e43b6f20751947ac00bfc44a3" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/SimulationManagementWithReliabilityFamilyPdus.xml", "sha256": "dbb32b2d541f715064397fc78e75b59a0be20b8b4a6a12150330c1fcd30bfdf1" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/SyntheticEnvironmentFamilyPdus.xml", "sha256": "5cac3fe21fa3fa8deb281e23ddebca164f4fcd22cf7b69928e2bf8c786908ee2" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/DIS7/WarfareFamilyPdus.xml", "sha256": "518506834ce8a054994358a0bce4a773e3eabadce6a8465f009d91d3585bcd38" },
+ { "path": "tools/OpenDisNet.Generator/Schemas/SISO/referenced-types-v36.json", "sha256": "a845598c022fd38b79000ac434f039772a6a679979f7f8dd618261c833330089" },
+ { "path": "tests/OpenDisNet.Tests/Conformance/Vectors/opendis7-java-default.json", "sha256": "1caf4b724f17358de22012faf55377ff21d215fbc2395ef32b8cdef07ec221a6" },
+ { "path": "tests/OpenDisNet.Tests/Conformance/Vectors/opendis7-java-populated.json", "sha256": "6558f75e153194618e3a3b77c737a48cbbc849e5c27e478e42dc81219e78b792" },
+ { "path": "src/OpenDisNet/PublicAPI.Shipped.txt", "sha256": "d7325bf2bf1e3fcb50dc152050f6f0275f6e147fa6eb085e1c0c51779a251ba6" }
+ ]
+}
diff --git a/tests/OpenDisNet.Tests/Conformance/ConformanceAuditTests.cs b/tests/OpenDisNet.Tests/Conformance/ConformanceAuditTests.cs
new file mode 100644
index 0000000..cc3ade4
--- /dev/null
+++ b/tests/OpenDisNet.Tests/Conformance/ConformanceAuditTests.cs
@@ -0,0 +1,86 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using OpenDisNet.Pdus;
+using OpenDisNet.Protocol;
+
+namespace OpenDisNet.Tests.Conformance;
+
+public sealed class ConformanceAuditTests
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ [Fact]
+ public void AuditedInputsHaveNotChanged()
+ {
+ string root = FindRepositoryRoot();
+ AuditManifest manifest = ReadManifest(root);
+
+ Assert.Equal("1.0", manifest.AuditVersion);
+ Assert.Equal(72, manifest.PduTypeCount);
+ Assert.Equal(233, manifest.ConcreteWireClassCount);
+ Assert.Equal(17, manifest.Artifacts.Length);
+
+ foreach (AuditArtifact artifact in manifest.Artifacts)
+ {
+ string path = Path.Combine(root, artifact.Path.Replace('/', Path.DirectorySeparatorChar));
+ Assert.True(File.Exists(path), $"Audited input is missing: {artifact.Path}");
+ byte[] normalized = Encoding.UTF8.GetBytes(File.ReadAllText(path).ReplaceLineEndings("\n"));
+ string actual = Convert.ToHexStringLower(SHA256.HashData(normalized));
+ Assert.Equal(artifact.Sha256, actual);
+ }
+ }
+
+ [Fact]
+ public void AuditAccountsForEveryStandardPduAndFamily()
+ {
+ string root = FindRepositoryRoot();
+ AuditManifest manifest = ReadManifest(root);
+ int[] auditedTypes = manifest.Families.SelectMany(x => x.Types).Order().ToArray();
+
+ Assert.Equal(Enumerable.Range(1, 72), auditedTypes);
+ Assert.Equal(72, manifest.Families.Sum(x => x.Count));
+ Assert.Equal(12, manifest.Families.Length);
+
+ foreach (AuditFamily family in manifest.Families)
+ {
+ Assert.Equal(family.Count, family.Types.Length);
+ foreach (int type in family.Types)
+ {
+ Pdu pdu = PduFactory.Create((PduType)type);
+ Assert.Equal((ProtocolFamily)family.Value, pdu.ProtocolFamily);
+ }
+ }
+ }
+
+ private static AuditManifest ReadManifest(string root)
+ {
+ string path = Path.Combine(root, "tests", "OpenDisNet.Tests", "Conformance", "Audit", "v1.0.json");
+ return JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions)
+ ?? throw new InvalidDataException("The 1.0 conformance audit manifest is empty.");
+ }
+
+ private static string FindRepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "OpenDisNet.slnx")))
+ directory = directory.Parent;
+ return directory?.FullName ?? throw new DirectoryNotFoundException("Could not locate the OpenDisNet repository root.");
+ }
+
+ private sealed record AuditManifest(
+ string AuditVersion,
+ string WireStandard,
+ string EnumerationStandard,
+ int PduTypeCount,
+ int ConcreteWireClassCount,
+ AuditFamily[] Families,
+ AuditArtifact[] Artifacts);
+
+ private sealed record AuditFamily(int Value, int Count, int[] Types);
+
+ private sealed record AuditArtifact(string Path, string Sha256);
+}
diff --git a/tests/OpenDisNet.Tests/Security/ParserSecurityTests.cs b/tests/OpenDisNet.Tests/Security/ParserSecurityTests.cs
new file mode 100644
index 0000000..c3ec45d
--- /dev/null
+++ b/tests/OpenDisNet.Tests/Security/ParserSecurityTests.cs
@@ -0,0 +1,92 @@
+using OpenDisNet.Pdus;
+using OpenDisNet.Protocol;
+using OpenDisNet.Tests.Conformance;
+
+namespace OpenDisNet.Tests.Security;
+
+public sealed class ParserSecurityTests
+{
+ private static readonly DisParseOptions BoundedOptions = new()
+ {
+ MaximumPduLength = 4_096,
+ RequireExactDatagramLength = true,
+ RequireVersion7 = true,
+ };
+
+ [Fact]
+ public void DeterministicRandomDatagramsNeverEscapeTryDeserialize()
+ {
+ var random = new Random(0x1278_0001);
+
+ for (int iteration = 0; iteration < 10_000; iteration++)
+ {
+ int length = iteration < 4_096 ? iteration : random.Next(0, 4_097);
+ byte[] datagram = new byte[length];
+ random.NextBytes(datagram);
+
+ if (DisSerializer.TryDeserialize(datagram, out IDisPdu? pdu, out DisParseError error, BoundedOptions))
+ {
+ Assert.NotNull(pdu);
+ byte[] canonical = DisSerializer.Serialize(pdu);
+ Assert.InRange(canonical.Length, DisHeader.MinimumSize, ushort.MaxValue);
+ }
+ else
+ {
+ Assert.NotEqual(DisParseErrorCode.None, error.Code);
+ Assert.InRange(error.Offset, 0, Math.Max(datagram.Length, DisHeader.Size));
+ }
+ }
+ }
+
+ [Fact]
+ public void BitMutationsAcrossEveryPduNeverEscapeTryDeserialize()
+ {
+ byte[] masks = [0x01, 0x80, 0xFF];
+
+ foreach (byte value in Enumerable.Range(1, 72).Select(x => (byte)x))
+ {
+ byte[] valid = DisSerializer.Serialize(DeterministicPduFixture.Create((PduType)value));
+ foreach (int offset in Enumerable.Range(0, valid.Length))
+ {
+ foreach (byte mask in masks)
+ {
+ byte[] mutated = (byte[])valid.Clone();
+ mutated[offset] ^= mask;
+
+ if (DisSerializer.TryDeserialize(mutated, out IDisPdu? parsed, out _, BoundedOptions))
+ {
+ Assert.NotNull(parsed);
+ byte[] canonical = DisSerializer.Serialize(parsed);
+ Assert.InRange(canonical.Length, DisHeader.MinimumSize, ushort.MaxValue);
+ }
+ }
+ }
+ }
+ }
+
+ [Fact]
+ public void ConfiguredLengthLimitIsEnforcedBeforeBodyParsing()
+ {
+ byte[] datagram = DisSerializer.Serialize(DeterministicPduFixture.Create(PduType.EntityState));
+ var options = new DisParseOptions { MaximumPduLength = datagram.Length - 1 };
+
+ Assert.False(DisSerializer.TryDeserialize(datagram, out _, out DisParseError error, options));
+ Assert.Equal(DisParseErrorCode.InvalidLength, error.Code);
+ Assert.Equal(8, error.Offset);
+ }
+
+ [Fact]
+ public void MaximumDeclaredLengthCannotAllocateFromATruncatedDatagram()
+ {
+ byte[] datagram =
+ [
+ 7, 1, (byte)PduType.EntityState, (byte)ProtocolFamily.EntityInformationInteraction,
+ 0, 0, 0, 0,
+ 0xFF, 0xFF,
+ 0, 0,
+ ];
+
+ Assert.False(DisSerializer.TryDeserialize(datagram, out _, out DisParseError error, BoundedOptions));
+ Assert.Equal(DisParseErrorCode.InvalidLength, error.Code);
+ }
+}