AB Legacy ab_server PCCC Docker fixture scaffold (#224) — Docker infrastructure + test-class shape in place; wire-level round-trip currently blocked by an ab_server-side PCCC coverage gap documented honestly in the fixture + coverage docs. Closes the Docker-infrastructure piece of #224; the remaining work is upstream (patch ab_server's PCCC server opcodes) or sideways (RSEmulate 500 golden-box tier, lab rig).

New project tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/ with four pieces. AbLegacyServerFixture — TCP probe against localhost:44818 (or AB_LEGACY_ENDPOINT override), distinct from AB_SERVER_ENDPOINT so both CIP + PCCC containers can run simultaneously. Single-public-ctor to satisfy xunit collection-fixture constraint. AbLegacyServerProfile + KnownProfiles carry the per-family (SLC500 / MicroLogix / PLC-5) ComposeProfile + Notes; drives per-theory parameterisation. AbLegacyFactAttribute / AbLegacyTheoryAttribute match the AB CIP skip-attribute pattern.

Docker/docker-compose.yml reuses the AB CIP otopcua-ab-server:libplctag-release image — `build:` block points at ../../AbCip.IntegrationTests/Docker context so `docker compose build` from here produces / reuses the same multi-stage build. Three compose profiles (slc500 / micrologix / plc5) with per-family `--plc` + `--tag=<file>[<size>]` flags matching the PCCC tag syntax (different from CIP's `Name:Type[size]`).

AbLegacyReadSmokeTests — one parametric theory reading N7:0 across all three families + one SLC500 write-then-read on N7:5. Targets the shape the driver would use against real hardware. Verified 2026-04-20 against a live SLC500 container: TCP probe passes + container accepts connections + libplctag negotiates session, but read/write returns BadCommunicationError (libplctag status 0x80050000). Root-caused to ab_server's PCCC server-side opcode coverage being narrower than libplctag's PCCC client expects — not a driver-side bug, not a scaffold bug, just an ab_server upstream limitation. Documented honestly in Docker/README.md + AbLegacy-Test-Fixture.md rather than skipping the tests or weakening assertions; tests now skip cleanly when container is absent, fail with clear message when container is up but the protocol gap surfaces. Operator resolves by filing an ab_server upstream patch, pointing AB_LEGACY_ENDPOINT at real hardware, or scaffolding an RSEmulate 500 golden-box tier.

Docker/README.md — Known limitations section leads with the PCCC round-trip gap (test date, failure signature, possible root causes, three resolution paths) before the pre-existing limitations (T/C file decomposition, ST file quirks, indirect addressing, DF1 serial). Reader can't miss the "scaffolded but blocked on upstream" framing.

docs/drivers/AbLegacy-Test-Fixture.md — TL;DR flipped from "no integration fixture" to "Docker scaffold in place; wire-level round-trip currently blocked by ab_server PCCC gap". What-the-fixture-is gains an Integration section. Follow-up candidates rewritten: #1 is now "fix ab_server PCCC upstream", #2 is RSEmulate 500 golden-box (with cost callouts matching our existing Logix Emulate + TwinCAT XAR scaffolds — license + Hyper-V conflict + binary project format), #3 is lab rig. Key-files list adds the four new files. docs/drivers/README.md coverage-map row updated from "no integration fixture" to "Docker scaffold via ab_server PCCC; wire-level round-trip currently blocked, docs call out resolution paths".

Solution file picks up the new tests/.../AbLegacy.IntegrationTests entry. AbLegacyDataType.Int used throughout (not Int16 — the enum uses SLC file-type naming). Build 0 errors; 2 smoke tests skip cleanly without container + fail with clear errors when container up (proving the infrastructure works end-to-end + the gap is specifically the ab_server protocol coverage, not the scaffold).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-20 13:26:19 -04:00
parent 2fe1a326dc
commit a0cf7c5860
8 changed files with 549 additions and 21 deletions

View File

@@ -0,0 +1,93 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// End-to-end smoke tests against the <c>ab_server</c> PCCC Docker container.
/// Promotes the AB Legacy driver from unit-only coverage (<c>FakeAbLegacyTag</c>)
/// to wire-level: real libplctag PCCC stack over real TCP against the ab_server
/// simulator. Parametrised over all three families (SLC 500 / MicroLogix / PLC-5)
/// via <c>[AbLegacyTheory]</c> + <c>[MemberData]</c>.
/// </summary>
[Collection(AbLegacyServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "ab_server-PCCC")]
public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim)
{
public static IEnumerable<object[]> Profiles =>
KnownProfiles.All.Select(p => new object[] { p });
[AbLegacyTheory]
[MemberData(nameof(Profiles))]
public async Task Driver_reads_seeded_N_file_from_ab_server_PCCC(AbLegacyServerProfile profile)
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
// PCCC PLCs use empty cip-path, but AbLegacyHostAddress still requires the
// /cip-path suffix to parse.
var deviceUri = $"ab://{sim.Host}:{sim.Port}/";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
Tags = [
new AbLegacyTagDefinition(
Name: "IntCounter",
DeviceHostAddress: deviceUri,
Address: "N7:0",
DataType: AbLegacyDataType.Int),
],
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, driverInstanceId: $"ablegacy-smoke-{profile.Family}");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var snapshots = await drv.ReadAsync(
["IntCounter"], TestContext.Current.CancellationToken);
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good,
$"N7:0 read must succeed against the {profile.Family} compose profile");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[AbLegacyFact]
public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
// PCCC PLCs use empty cip-path, but AbLegacyHostAddress still requires the
// /cip-path suffix to parse.
var deviceUri = $"ab://{sim.Host}:{sim.Port}/";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)],
Tags = [
new AbLegacyTagDefinition(
Name: "Scratch",
DeviceHostAddress: deviceUri,
Address: "N7:5",
DataType: AbLegacyDataType.Int,
Writable: true),
],
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, driverInstanceId: "ablegacy-smoke-rw");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
const short probe = 0x1234;
var writeResults = await drv.WriteAsync(
[new WriteRequest("Scratch", probe)],
TestContext.Current.CancellationToken);
writeResults.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good,
"PCCC N7:5 write must succeed end-to-end");
var readResults = await drv.ReadAsync(
["Scratch"], TestContext.Current.CancellationToken);
readResults.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
Convert.ToInt32(readResults.Single().Value).ShouldBe(probe);
}
}

View File

@@ -0,0 +1,157 @@
using System.Net.Sockets;
using Xunit;
using Xunit.Sdk;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// Reachability probe for the <c>ab_server</c> Docker container running in a PCCC
/// plc mode (<c>SLC500</c> / <c>Micrologix</c> / <c>PLC/5</c>). Same container image
/// the AB CIP integration suite uses — libplctag's <c>ab_server</c> supports both
/// CIP + PCCC families from one binary. Tests skip via
/// <see cref="AbLegacyFactAttribute"/> / <see cref="AbLegacyTheoryAttribute"/> when
/// the port isn't live, so <c>dotnet test</c> stays green on a fresh clone without
/// Docker running.
/// </summary>
/// <remarks>
/// Env-var overrides:
/// <list type="bullet">
/// <item><c>AB_LEGACY_ENDPOINT</c> — <c>host:port</c> of the PCCC-mode simulator.
/// Defaults to <c>localhost:44818</c> (EtherNet/IP port; ab_server's PCCC
/// emulation exposes PCCC-over-CIP on the same port as CIP itself).</item>
/// </list>
/// Distinct from <c>AB_SERVER_ENDPOINT</c> used by the AB CIP fixture so both
/// can point at different containers simultaneously during a combined test run.
/// </remarks>
public sealed class AbLegacyServerFixture : IAsyncLifetime
{
private const string EndpointEnvVar = "AB_LEGACY_ENDPOINT";
/// <summary>Standard EtherNet/IP port. PCCC-over-CIP rides on the same port as
/// native CIP; the differentiator is the <c>--plc</c> flag ab_server was started
/// with, not a different TCP listener.</summary>
public const int DefaultPort = 44818;
public string Host { get; } = "127.0.0.1";
public int Port { get; } = DefaultPort;
public string? SkipReason { get; }
public AbLegacyServerFixture()
{
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } raw)
{
var parts = raw.Split(':', 2);
Host = parts[0];
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
}
if (!TcpProbe(Host, Port))
{
SkipReason =
$"AB Legacy PCCC simulator at {Host}:{Port} not reachable within 2 s. " +
$"Start the Docker container (docker compose -f Docker/docker-compose.yml " +
$"--profile slc500 up -d) or override {EndpointEnvVar}.";
}
}
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
public static bool IsServerAvailable()
{
var (host, port) = ResolveEndpoint();
return TcpProbe(host, port);
}
private static (string Host, int Port) ResolveEndpoint()
{
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar);
if (raw is null) return ("127.0.0.1", DefaultPort);
var parts = raw.Split(':', 2);
var port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : DefaultPort;
return (parts[0], port);
}
private static bool TcpProbe(string host, int port)
{
try
{
using var client = new TcpClient();
var task = client.ConnectAsync(host, port);
return task.Wait(TimeSpan.FromSeconds(2)) && client.Connected;
}
catch { return false; }
}
}
/// <summary>
/// Per-family marker for the PCCC-mode compose profile a given test targets. The
/// compose file (<c>Docker/docker-compose.yml</c>) is the canonical source of truth
/// for which <c>--plc</c> mode + tags each family seeds; this record just ties a
/// family enum to its compose-profile name + operator-facing notes.
/// </summary>
public sealed record AbLegacyServerProfile(
AbLegacyPlcFamily Family,
string ComposeProfile,
string Notes);
/// <summary>Canonical profiles covering every PCCC family the driver supports.</summary>
public static class KnownProfiles
{
public static readonly AbLegacyServerProfile Slc500 = new(
Family: AbLegacyPlcFamily.Slc500,
ComposeProfile: "slc500",
Notes: "SLC 500 / 5/05 family. ab_server SLC500 mode covers N/F/B/L files.");
public static readonly AbLegacyServerProfile MicroLogix = new(
Family: AbLegacyPlcFamily.MicroLogix,
ComposeProfile: "micrologix",
Notes: "MicroLogix 1000 / 1100 / 1400. Shares N/F/B file-type coverage with SLC500; ST (ASCII strings) included.");
public static readonly AbLegacyServerProfile Plc5 = new(
Family: AbLegacyPlcFamily.Plc5,
ComposeProfile: "plc5",
Notes: "PLC-5 family. ab_server PLC/5 mode covers N/F/B; per-family quirks on ST / timer file layouts unit-tested only.");
public static IReadOnlyList<AbLegacyServerProfile> All { get; } =
[Slc500, MicroLogix, Plc5];
public static AbLegacyServerProfile ForFamily(AbLegacyPlcFamily family) =>
All.FirstOrDefault(p => p.Family == family)
?? throw new ArgumentOutOfRangeException(nameof(family), family, "No integration profile for this family.");
}
[Xunit.CollectionDefinition(Name)]
public sealed class AbLegacyServerCollection : Xunit.ICollectionFixture<AbLegacyServerFixture>
{
public const string Name = "AbLegacyServer";
}
/// <summary>
/// <c>[Fact]</c>-equivalent that skips when the PCCC simulator isn't reachable.
/// </summary>
public sealed class AbLegacyFactAttribute : FactAttribute
{
public AbLegacyFactAttribute()
{
if (!AbLegacyServerFixture.IsServerAvailable())
Skip = "AB Legacy PCCC simulator not reachable. Start the Docker container " +
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
"or set AB_LEGACY_ENDPOINT.";
}
}
/// <summary>
/// <c>[Theory]</c>-equivalent with the same gate as <see cref="AbLegacyFactAttribute"/>.
/// </summary>
public sealed class AbLegacyTheoryAttribute : TheoryAttribute
{
public AbLegacyTheoryAttribute()
{
if (!AbLegacyServerFixture.IsServerAvailable())
Skip = "AB Legacy PCCC simulator not reachable. Start the Docker container " +
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
"or set AB_LEGACY_ENDPOINT.";
}
}

View File

@@ -0,0 +1,140 @@
# AB Legacy PCCC integration-test fixture — `ab_server` (Docker)
[libplctag](https://github.com/libplctag/libplctag)'s `ab_server` supports
both CIP (ControlLogix / CompactLogix / Micro800) and PCCC (SLC 500 /
MicroLogix / PLC-5) families from one binary. This fixture reuses the AB
CIP Docker image (`otopcua-ab-server:libplctag-release`) with different
`--plc` flags. No new Dockerfile needed — the compose file's `build:`
block points at the AB CIP `Docker/` folder so `docker compose build`
from here reuses the same multi-stage build.
**Docker is the only supported launch path**; a fresh clone needs Docker
Desktop and nothing else.
| File | Purpose |
|---|---|
| [`docker-compose.yml`](docker-compose.yml) | Three per-family services (`slc500` / `micrologix` / `plc5`); all bind `:44818` |
## Run
From the repo root:
```powershell
# SLC 500 family — widest PCCC coverage
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests\Docker\docker-compose.yml --profile slc500 up
# Per-family
docker compose -f tests\...\Docker\docker-compose.yml --profile micrologix up
docker compose -f tests\...\Docker\docker-compose.yml --profile plc5 up
```
Detached + stop:
```powershell
docker compose -f tests\...\Docker\docker-compose.yml --profile slc500 up -d
docker compose -f tests\...\Docker\docker-compose.yml --profile slc500 down
```
First run builds the `otopcua-ab-server:libplctag-release` image (~3-5
min — clones libplctag + compiles `ab_server`). If the AB CIP fixture
already built the image locally, docker reuses the cached layers + this
runs in seconds. Only one family binds `:44818` at a time; to switch
families stop the current service + start another.
## Endpoint
- Default: `localhost:44818` (EtherNet/IP standard)
- Override with `AB_LEGACY_ENDPOINT=host:port` to point at a real SLC /
MicroLogix / PLC-5 PLC on its native port.
## Run the integration tests
In a separate shell with a container up:
```powershell
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests
```
`AbLegacyServerFixture` TCP-probes `localhost:44818` at collection init +
records a skip reason when unreachable. Tests use `[AbLegacyFact]` /
`[AbLegacyTheory]` which check the same probe.
## What each family seeds
PCCC tag format is `<file>[<size>]` without a type suffix — file letter
implies type:
- `N` = 16-bit signed integer
- `F` = 32-bit IEEE 754 float
- `B` = 1-bit boolean (stored as uint16, bit-addressable via `/n`)
- `L` = 32-bit signed integer (SLC 5/05 V15+ only)
- `ST` = 82-byte ASCII string (MicroLogix-specific extension)
| Family | Seeded tags | Notes |
|---|---|---|
| SLC 500 | `N7[10]`, `F8[10]`, `B3[10]`, `L19[10]` | Baseline; covers the four numeric file types a typical SLC project uses |
| MicroLogix | `B3[10]`, `N7[10]`, `L19[10]` | No `F8` — MicroLogix 1000 has no float file; use L19 when scaled integers aren't enough |
| PLC-5 | `N7[10]`, `F8[10]`, `B3[10]` | No `L` — PLC-5 predates the L file type; DINT equivalents went in integer files |
## Known limitations
### ab_server PCCC read/write round-trip (verified 2026-04-20)
**Scaffold is in place; wire-level round-trip does NOT currently pass
against `ab_server --plc=SLC500`.** With the SLC500 compose profile up,
TCP 44818 accepts connections and libplctag negotiates the session,
but the three smoke tests in `AbLegacyReadSmokeTests.cs` all fail at
read/write with `BadCommunicationError` (libplctag status `0x80050000`).
Possible root causes:
- ab_server's PCCC server-side opcode coverage may be narrower than
libplctag's PCCC client expects — the tool is primarily a CIP
server; PCCC was added later + is noted in libplctag docs as less
mature.
- libplctag's PCCC-over-CIP encapsulation may assume a real SLC 5/05
EtherNet/IP NIC's framing that ab_server doesn't emit.
The scaffold ships **as-is** because:
1. The Docker infrastructure + fixture pattern works cleanly (probe
passes, container lifecycle is clean, tests skip when absent).
2. The test classes target the correct shape for what the AB Legacy
driver would do against real hardware.
3. Pointing `AB_LEGACY_ENDPOINT` at a real SLC 5/05 / MicroLogix
1100 / 1400 should make the tests pass outright — the failure
mode is ab_server-specific, not driver-specific.
Resolution paths (pick one):
1. **File an ab_server bug** in `libplctag/libplctag` to expand PCCC
server-side coverage.
2. **Golden-box tier** via Rockwell RSEmulate 500 — closer to real
firmware, but license-gated + RSLinx-dependent.
3. **Lab rig** — used SLC 5/05 / MicroLogix 1100 on a dedicated
network; the authoritative path.
### Other known gaps (unchanged from ab_server)
- **Timer / Counter file decomposition** — PCCC T4 / C5 files contain
three-field structs (`.ACC` / `.PRE` / `.DN`). Not in ab_server's
scope; tests targeting `T4:0.ACC` stay unit-only.
- **ST (ASCII string) files** — real MicroLogix ST files have a length
field plus CRLF-sensitive semantics that don't round-trip cleanly.
- **Indirect addressing** (`N7:[N10:5]`) — not in ab_server's scope.
- **DF1 serial wire behaviour** — the whole ab_server path is TCP;
DF1 radio / serial fidelity needs real hardware.
See [`docs/drivers/AbLegacy-Test-Fixture.md`](../../../docs/drivers/AbLegacy-Test-Fixture.md)
for the full coverage map.
## References
- [libplctag on GitHub](https://github.com/libplctag/libplctag) — `ab_server`
lives under `src/tools/ab_server/`
- [`docs/drivers/AbLegacy-Test-Fixture.md`](../../../docs/drivers/AbLegacy-Test-Fixture.md)
— coverage map + gap inventory
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md)
§Docker fixtures — full fixture inventory
- [`../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/`](../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/)
— the shared Dockerfile this compose file's `build:` block references

View File

@@ -0,0 +1,74 @@
# AB Legacy PCCC integration-test fixture — ab_server in PCCC mode.
#
# Same image as the AB CIP fixture (otopcua-ab-server:libplctag-release).
# The build context points at the AB CIP Docker folder one directory over
# so `docker compose build` from here produces the same image if it
# doesn't already exist; if it does, docker's cache reuses the layer.
#
# One service per PCCC family. All bind :44818 on the host; run one at a
# time. PCCC tag format differs from CIP: `<file>[<size>]` without a
# type suffix since the type is implicit in the file letter (N = INT,
# F = REAL, B = bit-packed, L = DINT).
#
# Usage:
# docker compose --profile slc500 up
# docker compose --profile micrologix up
# docker compose --profile plc5 up
services:
slc500:
profiles: ["slc500"]
build:
context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
dockerfile: Dockerfile
image: otopcua-ab-server:libplctag-release
container_name: otopcua-ab-server-slc500
restart: "no"
ports:
- "44818:44818"
command: [
"ab_server",
"--plc=SLC500",
"--port=44818",
"--tag=N7[10]",
"--tag=F8[10]",
"--tag=B3[10]",
"--tag=L19[10]"
]
micrologix:
profiles: ["micrologix"]
image: otopcua-ab-server:libplctag-release
build:
context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
dockerfile: Dockerfile
container_name: otopcua-ab-server-micrologix
restart: "no"
ports:
- "44818:44818"
command: [
"ab_server",
"--plc=Micrologix",
"--port=44818",
"--tag=B3[10]",
"--tag=N7[10]",
"--tag=L19[10]"
]
plc5:
profiles: ["plc5"]
image: otopcua-ab-server:libplctag-release
build:
context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
dockerfile: Dockerfile
container_name: otopcua-ab-server-plc5
restart: "no"
ports:
- "44818:44818"
command: [
"ab_server",
"--plc=PLC/5",
"--port=44818",
"--tag=N7[10]",
"--tag=F8[10]",
"--tag=B3[10]"
]

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
</ItemGroup>
<ItemGroup>
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>