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); + } +}