diff --git a/src/EncDotNet.S57/EncDotNet.S57.csproj b/src/EncDotNet.S57/EncDotNet.S57.csproj
index 5c22dda..a008870 100644
--- a/src/EncDotNet.S57/EncDotNet.S57.csproj
+++ b/src/EncDotNet.S57/EncDotNet.S57.csproj
@@ -11,6 +11,9 @@
+
+
+
diff --git a/src/EncDotNet.S57/ExchangeSets/IS57ExchangeSetVerifier.cs b/src/EncDotNet.S57/ExchangeSets/IS57ExchangeSetVerifier.cs
new file mode 100644
index 0000000..d42ea81
--- /dev/null
+++ b/src/EncDotNet.S57/ExchangeSets/IS57ExchangeSetVerifier.cs
@@ -0,0 +1,41 @@
+using Microsoft.Extensions.Logging;
+
+namespace EncDotNet.S57.ExchangeSets;
+
+///
+/// Verifies the integrity of the files referenced by an S-57 exchange set catalogue.
+///
+///
+///
+/// Verification covers the per-file CRC checksums declared in the CATD CRCS subfield of
+/// CATALOG.031 (S-57 Edition 3.1 Part 3, clause 3.4) and, in future, S-63 digital
+/// signatures. The two dimensions are reported independently on each
+/// .
+///
+///
+/// Implementations are non-throwing: per-file failures are surfaced as
+/// values rather than exceptions.
+///
+///
+public interface IS57ExchangeSetVerifier
+{
+ ///
+ /// Verifies the integrity metadata in against the files located
+ /// under .
+ ///
+ /// The absolute path to the root directory of the exchange set.
+ /// The parsed CATALOG.031 whose entries to verify.
+ ///
+ /// Optional S-63 trust anchor options. Currently unused because signature verification is a
+ /// seam; supply when S-63 support is available.
+ ///
+ /// An optional logger for reporting verification warnings.
+ /// A token to cancel the operation.
+ /// A result enumerating per-file verification outcomes without throwing.
+ Task VerifyAsync(
+ string rootPath,
+ S57Catalog catalog,
+ S63TrustAnchorOptions? trustAnchors = null,
+ ILogger? logger = null,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/EncDotNet.S57/ExchangeSets/IS63SignatureVerifier.cs b/src/EncDotNet.S57/ExchangeSets/IS63SignatureVerifier.cs
new file mode 100644
index 0000000..e49363c
--- /dev/null
+++ b/src/EncDotNet.S57/ExchangeSets/IS63SignatureVerifier.cs
@@ -0,0 +1,34 @@
+namespace EncDotNet.S57.ExchangeSets;
+
+///
+/// Verifies the S-63 digital signature of a file in an S-57 exchange set.
+///
+///
+///
+/// This is a seam: the full S-63 ENC Data Protection Scheme (RSA/DSA signatures
+/// over the SA-issued data server certificate chain) is not yet implemented and is intended to
+/// land alongside S-63 decryption support. The interface is published now so that the
+/// surface is stable in advance.
+///
+///
+/// Implementations must be non-throwing and report failures via the returned
+/// .
+///
+///
+public interface IS63SignatureVerifier
+{
+ ///
+ /// Verifies the detached S-63 signature associated with the file at .
+ ///
+ /// The absolute path to the file whose signature is being verified.
+ /// Trust anchor options controlling certificate chain validation.
+ /// A token to cancel the operation.
+ ///
+ /// The signature verification outcome. Returns
+ /// when no signature material is present for the file.
+ ///
+ Task VerifySignatureAsync(
+ string filePath,
+ S63TrustAnchorOptions trustAnchors,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/EncDotNet.S57/ExchangeSets/S57Crc32.cs b/src/EncDotNet.S57/ExchangeSets/S57Crc32.cs
new file mode 100644
index 0000000..abe595f
--- /dev/null
+++ b/src/EncDotNet.S57/ExchangeSets/S57Crc32.cs
@@ -0,0 +1,91 @@
+namespace EncDotNet.S57.ExchangeSets;
+
+///
+/// Computes the 32-bit cyclic redundancy check (CRC-32) used by S-57 exchange sets.
+///
+///
+///
+/// S-57 Edition 3.1 Part 3, clause 3.4 defers the choice of CRC algorithm to the relevant
+/// product specification. The ENC product specification (Appendix B.1) uses the ubiquitous
+/// CRC-32 employed by the ZIP file format and Ethernet: the reflected polynomial
+/// 0xEDB88320 (i.e. 0x04C11DB7 reversed), an initial value of all ones, and a
+/// final XOR of all ones. This is the same algorithm exposed by System.IO.Hashing.Crc32
+/// and zlib.
+///
+///
+/// Implemented internally to avoid adding a package dependency for a single, well-known
+/// table-based computation.
+///
+///
+internal static class S57Crc32
+{
+ private const uint Polynomial = 0xEDB88320u;
+
+ private static readonly uint[] Table = BuildTable();
+
+ private static uint[] BuildTable()
+ {
+ var table = new uint[256];
+ for (uint i = 0; i < 256; i++)
+ {
+ uint crc = i;
+ for (int bit = 0; bit < 8; bit++)
+ {
+ crc = (crc & 1) != 0 ? (crc >> 1) ^ Polynomial : crc >> 1;
+ }
+
+ table[i] = crc;
+ }
+
+ return table;
+ }
+
+ ///
+ /// Computes the CRC-32 of the bytes read from .
+ ///
+ /// The stream to read to completion.
+ /// A token to cancel the operation.
+ /// The computed CRC-32 value.
+ public static async Task ComputeAsync(Stream stream, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+
+ uint crc = 0xFFFFFFFFu;
+ var buffer = new byte[81920];
+ int bytesRead;
+
+ while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
+ {
+ for (int i = 0; i < bytesRead; i++)
+ {
+ crc = (crc >> 8) ^ Table[(crc ^ buffer[i]) & 0xFF];
+ }
+ }
+
+ return crc ^ 0xFFFFFFFFu;
+ }
+
+ ///
+ /// Computes the CRC-32 of the supplied bytes.
+ ///
+ /// The data to checksum.
+ /// The computed CRC-32 value.
+ public static uint Compute(ReadOnlySpan data)
+ {
+ uint crc = 0xFFFFFFFFu;
+ foreach (byte b in data)
+ {
+ crc = (crc >> 8) ^ Table[(crc ^ b) & 0xFF];
+ }
+
+ return crc ^ 0xFFFFFFFFu;
+ }
+
+ ///
+ /// Formats a CRC-32 value as the 8-character, upper-case hexadecimal string used in the
+ /// CATD CRCS subfield (most-significant byte first).
+ ///
+ /// The CRC-32 value.
+ /// An 8-character upper-case hexadecimal string.
+ public static string Format(uint crc) => crc.ToString("X8");
+}
diff --git a/src/EncDotNet.S57/ExchangeSets/S57ExchangeSet.cs b/src/EncDotNet.S57/ExchangeSets/S57ExchangeSet.cs
index eb6f2cb..4597042 100644
--- a/src/EncDotNet.S57/ExchangeSets/S57ExchangeSet.cs
+++ b/src/EncDotNet.S57/ExchangeSets/S57ExchangeSet.cs
@@ -70,6 +70,37 @@ public Task ReadCatalogAsync(string rootPath, ILogger? logger = null
return S57CatalogReader.ReadFromFileAsync(catalogPath, logger, cancellationToken);
}
+ ///
+ /// Verifies the integrity of the files referenced by this exchange set's CATALOG.031.
+ ///
+ ///
+ /// This is an opt-in, non-throwing operation: it reads the catalogue and validates each
+ /// entry's CRC checksum against the corresponding file on disk, returning the per-file
+ /// outcomes. S-63 digital-signature verification is a seam and currently reports every file
+ /// as .
+ ///
+ /// The absolute path to the root directory of the exchange set.
+ /// Optional S-63 trust anchor options (currently unused).
+ /// An optional logger for reporting verification warnings.
+ /// A token to cancel the operation.
+ /// A result enumerating per-file verification outcomes.
+ /// is .
+ public async Task VerifyAsync(
+ string rootPath,
+ S63TrustAnchorOptions? trustAnchors = null,
+ ILogger? logger = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (CatalogFileName is null)
+ {
+ throw new InvalidOperationException("The exchange set does not contain a catalog file.");
+ }
+
+ var catalog = await ReadCatalogAsync(rootPath, logger, cancellationToken).ConfigureAwait(false);
+ var verifier = new S57ExchangeSetVerifier();
+ return await verifier.VerifyAsync(rootPath, catalog, trustAnchors, logger, cancellationToken).ConfigureAwait(false);
+ }
+
///
/// Reads the base cell file and applies any update files in order, returning the resulting document.
///
diff --git a/src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerificationResult.cs b/src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerificationResult.cs
new file mode 100644
index 0000000..486d5e5
--- /dev/null
+++ b/src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerificationResult.cs
@@ -0,0 +1,40 @@
+using System.Collections.Immutable;
+
+namespace EncDotNet.S57.ExchangeSets;
+
+///
+/// The aggregate result of verifying all files referenced by an S-57 exchange set catalogue.
+///
+public sealed class S57ExchangeSetVerificationResult
+{
+ /// Gets the per-file verification results.
+ public required ImmutableArray FileResults { get; init; }
+
+ ///
+ /// Gets a value indicating whether the exchange set has no integrity violations: every
+ /// file's checksum is or
+ /// (the CRC is optional in S-57, so its
+ /// absence is not a failure), and every signature is
+ /// or .
+ ///
+ public bool AllValid => FileResults.All(r =>
+ r.ChecksumOutcome is S57VerificationOutcome.Ok or S57VerificationOutcome.NoChecksum
+ && r.SignatureOutcome is S57VerificationOutcome.Ok or S57VerificationOutcome.NotSigned);
+
+ /// Gets a value indicating whether at least one file's CRC checksum did not match.
+ public bool HasChecksumMismatches => FileResults.Any(r => r.ChecksumOutcome == S57VerificationOutcome.ChecksumMismatch);
+
+ /// Gets a value indicating whether at least one referenced file was missing on disk.
+ public bool HasMissingFiles => FileResults.Any(r => r.ChecksumOutcome == S57VerificationOutcome.FileMissing);
+
+ ///
+ /// Gets a value indicating whether at least one file had an invalid digital signature.
+ ///
+ public bool HasInvalidSignatures => FileResults.Any(r => r.SignatureOutcome == S57VerificationOutcome.SignatureInvalid);
+
+ ///
+ /// Gets a value indicating whether no file carries a digital signature (all are
+ /// ). Unencrypted ENC exchange sets are unsigned.
+ ///
+ public bool IsUnsigned => FileResults.All(r => r.SignatureOutcome == S57VerificationOutcome.NotSigned);
+}
diff --git a/src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerifier.cs b/src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerifier.cs
new file mode 100644
index 0000000..a5a95bc
--- /dev/null
+++ b/src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerifier.cs
@@ -0,0 +1,175 @@
+using System.Collections.Immutable;
+using Microsoft.Extensions.Logging;
+
+namespace EncDotNet.S57.ExchangeSets;
+
+///
+/// Default implementation of .
+///
+///
+///
+/// Validates each catalogue entry's CRC checksum (CATD CRCS subfield) against the
+/// corresponding file on disk using the CRC-32 algorithm described by .
+///
+///
+/// S-63 digital-signature verification is delegated to an optional
+/// . When none is supplied (the default), every file is
+/// reported as , which matches unencrypted ENC
+/// exchange sets.
+///
+///
+public sealed class S57ExchangeSetVerifier : IS57ExchangeSetVerifier
+{
+ private readonly IS63SignatureVerifier? _signatureVerifier;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// An optional S-63 signature verifier. When , signature verification
+ /// is skipped and all files report .
+ ///
+ public S57ExchangeSetVerifier(IS63SignatureVerifier? signatureVerifier = null)
+ {
+ _signatureVerifier = signatureVerifier;
+ }
+
+ ///
+ public async Task VerifyAsync(
+ string rootPath,
+ S57Catalog catalog,
+ S63TrustAnchorOptions? trustAnchors = null,
+ ILogger? logger = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(rootPath);
+ ArgumentNullException.ThrowIfNull(catalog);
+
+ var anchors = trustAnchors ?? new S63TrustAnchorOptions();
+ var results = ImmutableArray.CreateBuilder(catalog.Entries.Count);
+
+ foreach (var entry in catalog.Entries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ results.Add(await VerifyEntryAsync(rootPath, entry, anchors, logger, cancellationToken).ConfigureAwait(false));
+ }
+
+ return new S57ExchangeSetVerificationResult { FileResults = results.ToImmutable() };
+ }
+
+ private async Task VerifyEntryAsync(
+ string rootPath,
+ S57CatalogEntry entry,
+ S63TrustAnchorOptions trustAnchors,
+ ILogger? logger,
+ CancellationToken cancellationToken)
+ {
+ string displayName = string.IsNullOrEmpty(entry.FileName) ? entry.LongFileName : entry.FileName;
+
+ // An entry without a declared CRC carries nothing to verify (e.g. the CATALOG.031
+ // self-reference). Report NoChecksum without requiring the file to be present.
+ if (string.IsNullOrWhiteSpace(entry.CrcChecksum))
+ {
+ return new S57FileVerificationResult
+ {
+ FileName = displayName,
+ ChecksumOutcome = S57VerificationOutcome.NoChecksum,
+ SignatureOutcome = S57VerificationOutcome.NotSigned,
+ };
+ }
+
+ string? filePath = ResolveFilePath(rootPath, entry);
+ if (filePath is null)
+ {
+ logger?.LogWarning("Exchange set file '{FileName}' referenced by the catalog was not found under '{RootPath}'.", displayName, rootPath);
+ return new S57FileVerificationResult
+ {
+ FileName = displayName,
+ ChecksumOutcome = S57VerificationOutcome.FileMissing,
+ SignatureOutcome = S57VerificationOutcome.NotSigned,
+ ExpectedCrc = entry.CrcChecksum,
+ };
+ }
+
+ S57VerificationOutcome checksumOutcome;
+ string? actualCrc = null;
+ string? detail = null;
+
+ try
+ {
+ await using var stream = new FileStream(
+ filePath, FileMode.Open, FileAccess.Read, FileShare.Read,
+ bufferSize: 81920, useAsync: true);
+
+ uint crc = await S57Crc32.ComputeAsync(stream, cancellationToken).ConfigureAwait(false);
+ actualCrc = S57Crc32.Format(crc);
+
+ checksumOutcome = string.Equals(actualCrc, NormalizeCrc(entry.CrcChecksum), StringComparison.OrdinalIgnoreCase)
+ ? S57VerificationOutcome.Ok
+ : S57VerificationOutcome.ChecksumMismatch;
+
+ if (checksumOutcome == S57VerificationOutcome.ChecksumMismatch)
+ {
+ logger?.LogWarning(
+ "CRC mismatch for '{FileName}': expected {Expected}, computed {Actual}.",
+ displayName, entry.CrcChecksum, actualCrc);
+ }
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ checksumOutcome = S57VerificationOutcome.Error;
+ detail = ex.Message;
+ logger?.LogWarning(ex, "Failed to verify CRC for '{FileName}'.", displayName);
+ }
+
+ var signatureOutcome = _signatureVerifier is null
+ ? S57VerificationOutcome.NotSigned
+ : await _signatureVerifier.VerifySignatureAsync(filePath, trustAnchors, cancellationToken).ConfigureAwait(false);
+
+ return new S57FileVerificationResult
+ {
+ FileName = displayName,
+ ChecksumOutcome = checksumOutcome,
+ SignatureOutcome = signatureOutcome,
+ ExpectedCrc = entry.CrcChecksum,
+ ActualCrc = actualCrc,
+ Detail = detail,
+ };
+ }
+
+ ///
+ /// Resolves a catalogue entry to an existing file under the exchange set root, trying the
+ /// short first and then the
+ /// . Returns if neither
+ /// candidate exists.
+ ///
+ private static string? ResolveFilePath(string rootPath, S57CatalogEntry entry)
+ {
+ foreach (string? candidate in new[] { entry.FileName, entry.LongFileName })
+ {
+ if (string.IsNullOrWhiteSpace(candidate))
+ {
+ continue;
+ }
+
+ string relative = candidate.Replace('\\', '/');
+ string fullPath = Path.Combine(rootPath, Path.Combine(relative.Split('/')));
+ if (File.Exists(fullPath))
+ {
+ return fullPath;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Normalizes a CRC string from the catalogue for comparison: trims whitespace and pads to
+ /// the canonical 8 hexadecimal digits (the value is stored most-significant byte first).
+ ///
+ private static string NormalizeCrc(string crc)
+ {
+ string trimmed = crc.Trim();
+ return trimmed.Length < 8 ? trimmed.PadLeft(8, '0') : trimmed;
+ }
+}
diff --git a/src/EncDotNet.S57/ExchangeSets/S57FileVerificationResult.cs b/src/EncDotNet.S57/ExchangeSets/S57FileVerificationResult.cs
new file mode 100644
index 0000000..71825f1
--- /dev/null
+++ b/src/EncDotNet.S57/ExchangeSets/S57FileVerificationResult.cs
@@ -0,0 +1,47 @@
+namespace EncDotNet.S57.ExchangeSets;
+
+///
+/// The verification result for a single file referenced by an S-57 exchange set catalogue.
+///
+///
+/// The checksum and digital-signature outcomes are tracked as independent dimensions: a file
+/// may have a valid CRC checksum yet carry no signature (the common case for unencrypted ENCs),
+/// or vice versa. Inspect and
+/// separately.
+///
+public sealed class S57FileVerificationResult
+{
+ /// Gets the name of the file as declared in the catalogue (CATD FILE subfield).
+ public required string FileName { get; init; }
+
+ ///
+ /// Gets the outcome of CRC checksum verification for this file.
+ ///
+ ///
+ /// Expected values are ,
+ /// ,
+ /// ,
+ /// , or
+ /// .
+ ///
+ public required S57VerificationOutcome ChecksumOutcome { get; init; }
+
+ ///
+ /// Gets the outcome of digital-signature (S-63) verification for this file.
+ ///
+ ///
+ /// S-63 signature verification is not yet implemented; this currently reports
+ /// for all files. The property exists as a
+ /// seam to be populated when S-63 support lands.
+ ///
+ public S57VerificationOutcome SignatureOutcome { get; init; } = S57VerificationOutcome.NotSigned;
+
+ /// Gets the CRC checksum declared in the catalogue, if any.
+ public string? ExpectedCrc { get; init; }
+
+ /// Gets the CRC checksum computed from the file on disk, if it was computed.
+ public string? ActualCrc { get; init; }
+
+ /// Gets an optional detail message (e.g. an exception message on ).
+ public string? Detail { get; init; }
+}
diff --git a/src/EncDotNet.S57/ExchangeSets/S57VerificationOutcome.cs b/src/EncDotNet.S57/ExchangeSets/S57VerificationOutcome.cs
new file mode 100644
index 0000000..95b5c9f
--- /dev/null
+++ b/src/EncDotNet.S57/ExchangeSets/S57VerificationOutcome.cs
@@ -0,0 +1,51 @@
+namespace EncDotNet.S57.ExchangeSets;
+
+///
+/// The outcome of verifying a single integrity dimension (checksum or digital signature)
+/// of a file referenced by an S-57 exchange set catalogue.
+///
+///
+///
+/// The member names are intentionally aligned with the S-100 exchange set verifier
+/// (EncDotNet.S100.ExchangeSets.VerificationOutcome) so that a future S-57→S-101
+/// bridge can treat both schemes uniformly. The S-57 enumeration additionally defines the
+/// checksum-specific members and .
+///
+///
+/// Checksum verification is governed by S-57 Edition 3.1 Part 3, clause 3.4 and the relevant
+/// ENC product specification (Appendix B.1). Signature verification is governed by the S-63
+/// ENC Data Protection Scheme and is currently exposed as a seam only.
+///
+///
+public enum S57VerificationOutcome
+{
+ /// The dimension was verified successfully.
+ Ok,
+
+ /// The file carries no digital signature.
+ NotSigned,
+
+ /// The digital signature does not match the file content.
+ SignatureInvalid,
+
+ /// The signing certificate is not trusted by the configured trust anchors.
+ CertificateUntrusted,
+
+ /// The signing certificate has expired.
+ CertificateExpired,
+
+ /// The referenced file was not found on disk.
+ FileMissing,
+
+ /// The referenced certificate was not found.
+ CertificateNotFound,
+
+ /// An unexpected error occurred during verification.
+ Error,
+
+ /// The catalogue entry declares no CRC checksum to verify against.
+ NoChecksum,
+
+ /// The computed CRC checksum does not match the value declared in the catalogue.
+ ChecksumMismatch,
+}
diff --git a/src/EncDotNet.S57/ExchangeSets/S63TrustAnchorOptions.cs b/src/EncDotNet.S57/ExchangeSets/S63TrustAnchorOptions.cs
new file mode 100644
index 0000000..3b17965
--- /dev/null
+++ b/src/EncDotNet.S57/ExchangeSets/S63TrustAnchorOptions.cs
@@ -0,0 +1,32 @@
+using System.Security.Cryptography.X509Certificates;
+
+namespace EncDotNet.S57.ExchangeSets;
+
+///
+/// Options that configure the trust anchors used during S-63 exchange set signature verification.
+///
+///
+///
+/// Mirrors the S-100 TrustAnchorOptions so that callers can configure both schemes
+/// uniformly. Under the S-63 ENC Data Protection Scheme the IHO Scheme Administrator (SA)
+/// public key is the root of trust; for interoperability testing the IHO publishes test SA keys.
+///
+///
+/// S-63 signature verification is not yet implemented. This type is provided as a seam so that
+/// the public verification API is stable before S-63 support lands.
+///
+///
+public sealed class S63TrustAnchorOptions
+{
+ ///
+ /// Gets the trusted root certificates (IHO Scheme Administrator public keys). When empty,
+ /// certificate chain validation is governed by .
+ ///
+ public IReadOnlyList TrustedRoots { get; init; } = [];
+
+ ///
+ /// Gets a value indicating whether signature verification proceeds even when the signing
+ /// certificate cannot be chained to a trusted root. Useful for development and inspection.
+ ///
+ public bool AllowUntrustedCertificates { get; init; }
+}
diff --git a/src/EncDotNet.S57/README.md b/src/EncDotNet.S57/README.md
index 5bcac4e..6618228 100644
--- a/src/EncDotNet.S57/README.md
+++ b/src/EncDotNet.S57/README.md
@@ -122,6 +122,47 @@ foreach (var entry in catalog.Entries)
}
```
+### Verifying Exchange Set Integrity
+
+The `CATALOG.031` file records a CRC-32 checksum (CATD `CRCS` subfield) for each chart file.
+`S57ExchangeSet.VerifyAsync` validates those checksums against the files on disk. It is
+opt-in and non-throwing — per-file results are returned rather than raised as exceptions:
+
+```csharp
+using EncDotNet.S57.ExchangeSets;
+
+var exchangeSet = S57ExchangeSetReader.Read("ENC_ROOT");
+var result = await exchangeSet.VerifyAsync("ENC_ROOT");
+
+if (result.AllValid)
+{
+ Console.WriteLine("All checksums valid.");
+}
+else
+{
+ foreach (var file in result.FileResults)
+ {
+ Console.WriteLine($"{file.FileName}: checksum={file.ChecksumOutcome}, signature={file.SignatureOutcome}");
+ }
+}
+```
+
+The checksum and S-63 digital-signature outcomes are tracked as **independent dimensions** on
+each `S57FileVerificationResult`. CRC-32 checksum verification is fully implemented;
+**S-63 signature verification is currently a seam** (`IS63SignatureVerifier`,
+`S63TrustAnchorOptions`) reporting `NotSigned`, to be completed alongside future S-63
+decryption support.
+
+| `S57VerificationOutcome` | Meaning |
+|---|---|
+| `Ok` | The dimension verified successfully |
+| `NoChecksum` | The catalogue entry declares no CRC to verify against |
+| `ChecksumMismatch` | The computed CRC does not match the catalogue value |
+| `FileMissing` | The referenced file was not found on disk |
+| `NotSigned` | The file carries no digital signature |
+| `SignatureInvalid` / `CertificateUntrusted` / `CertificateExpired` / `CertificateNotFound` | S-63 signature outcomes (seam) |
+| `Error` | An unexpected error occurred during verification |
+
## Key Types
### Core (Document-Level)
@@ -155,6 +196,8 @@ foreach (var entry in catalog.Entries)
| `S57CatalogReader` | Reads a catalog file into an `S57Catalog` |
| `S57ExchangeSet` | A complete exchange set with catalog and all referenced charts |
| `S57ExchangeSetReader` | Reads an exchange set directory |
+| `S57ExchangeSetVerifier` | Verifies per-file CRC checksums (and S-63 signature seam) against the files on disk |
+| `S57ExchangeSetVerificationResult` | Aggregate verification result with per-file outcomes |
## Background
diff --git a/tests/EndDotNet.UnitTests/S57ExchangeSetVerifierTests.cs b/tests/EndDotNet.UnitTests/S57ExchangeSetVerifierTests.cs
new file mode 100644
index 0000000..33fd8a8
--- /dev/null
+++ b/tests/EndDotNet.UnitTests/S57ExchangeSetVerifierTests.cs
@@ -0,0 +1,431 @@
+using System.Text;
+using EncDotNet.S57.ExchangeSets;
+
+namespace EndDotNet.UnitTests;
+
+///
+/// Unit tests for , , and the
+/// associated verification result model.
+///
+public class S57ExchangeSetVerifierTests
+{
+ #region ISO 8211 catalog fixture helpers (mirrors S57CatalogReaderTests)
+
+ private const byte UnitTerminator = 0x1F;
+ private const byte FieldTerminator = 0x1E;
+
+ private static byte[] CreateCatalogDocument(params byte[][] dataRecords)
+ {
+ var ddr = CreateCatalogDdr();
+ var totalSize = ddr.Length + dataRecords.Sum(r => r.Length);
+ var result = new byte[totalSize];
+ var offset = 0;
+
+ Array.Copy(ddr, 0, result, offset, ddr.Length);
+ offset += ddr.Length;
+
+ foreach (var record in dataRecords)
+ {
+ Array.Copy(record, 0, result, offset, record.Length);
+ offset += record.Length;
+ }
+
+ return result;
+ }
+
+ private static byte[] CreateCatalogDdr()
+ {
+ var fields = new List<(string tag, byte[] data)>
+ {
+ ("0001", CreateDdrFieldData("", "", "()")),
+ ("CATD", CreateDdrFieldData(
+ "CATD",
+ "RCNM!RCID!FILE!LFIL!VOLM!IMPL!SLAT!WLON!NLAT!ELON!CRCS!COMT",
+ "(A,I,A,A,A,A,A,A,A,A,A,A)"))
+ };
+
+ return CreateDdrRecord(fields.ToArray());
+ }
+
+ private static byte[] CreateDdrFieldData(string fieldName, string subfieldDescriptors, string formatControls)
+ {
+ using var ms = new MemoryStream();
+ ms.WriteByte((byte)'0');
+ ms.WriteByte((byte)'6');
+
+ var descriptors = string.IsNullOrEmpty(fieldName)
+ ? subfieldDescriptors
+ : (string.IsNullOrEmpty(subfieldDescriptors) ? fieldName : $"{fieldName}!{subfieldDescriptors}");
+
+ if (!string.IsNullOrEmpty(descriptors))
+ {
+ ms.Write(Encoding.ASCII.GetBytes(descriptors));
+ }
+
+ ms.WriteByte(UnitTerminator);
+ ms.Write(Encoding.ASCII.GetBytes(formatControls));
+ ms.WriteByte(FieldTerminator);
+
+ return ms.ToArray();
+ }
+
+ private static byte[] CreateDdrRecord((string tag, byte[] data)[] fields)
+ {
+ var directoryEntries = new List();
+ var currentPosition = 0;
+
+ foreach (var (tag, data) in fields)
+ {
+ var entry = Encoding.ASCII.GetBytes($"{tag}{data.Length:D3}{currentPosition:D3}");
+ directoryEntries.Add(entry);
+ currentPosition += data.Length;
+ }
+
+ var directorySize = directoryEntries.Sum(e => e.Length);
+ var baseAddress = 24 + directorySize + 1;
+ var totalFieldSize = fields.Sum(f => f.data.Length);
+ var recordLength = baseAddress + totalFieldSize;
+
+ var leader = Encoding.ASCII.GetBytes($"{recordLength:D5}3LE1 02{baseAddress:D5} 3304");
+
+ var record = new byte[recordLength];
+ var offset = 0;
+
+ Array.Copy(leader, 0, record, offset, leader.Length);
+ offset += leader.Length;
+
+ foreach (var entry in directoryEntries)
+ {
+ Array.Copy(entry, 0, record, offset, entry.Length);
+ offset += entry.Length;
+ }
+
+ record[offset++] = FieldTerminator;
+
+ foreach (var (_, data) in fields)
+ {
+ Array.Copy(data, 0, record, offset, data.Length);
+ offset += data.Length;
+ }
+
+ return record;
+ }
+
+ private static byte[] CreateCatdRecord(
+ string rcnm = "CD",
+ uint rcid = 1,
+ string file = "US5WA51M.000",
+ string lfil = "US5WA51M.000",
+ string volm = "V01X01",
+ string impl = "BIN",
+ string? slat = null,
+ string? wlon = null,
+ string? nlat = null,
+ string? elon = null,
+ string crcs = "",
+ string comt = "")
+ {
+ using var ms = new MemoryStream();
+
+ WriteString(ms, rcnm);
+ WriteString(ms, rcid.ToString());
+ WriteString(ms, file);
+ WriteString(ms, lfil);
+ WriteString(ms, volm);
+ WriteString(ms, impl);
+ WriteString(ms, slat ?? "");
+ WriteString(ms, wlon ?? "");
+ WriteString(ms, nlat ?? "");
+ WriteString(ms, elon ?? "");
+ WriteString(ms, crcs);
+ WriteString(ms, comt);
+ ms.WriteByte(FieldTerminator);
+
+ return CreateDataRecord("CATD", ms.ToArray());
+ }
+
+ private static byte[] CreateDataRecord(string tag, byte[] fieldData)
+ {
+ var directoryEntry = Encoding.ASCII.GetBytes($"{tag}{fieldData.Length:D3}000");
+ var baseAddress = 24 + directoryEntry.Length + 1;
+ var recordLength = baseAddress + fieldData.Length;
+
+ var leader = Encoding.ASCII.GetBytes($"{recordLength:D5}3DE1 00{baseAddress:D5} 3304");
+
+ var record = new byte[recordLength];
+ var offset = 0;
+
+ Array.Copy(leader, 0, record, offset, leader.Length);
+ offset += leader.Length;
+
+ Array.Copy(directoryEntry, 0, record, offset, directoryEntry.Length);
+ offset += directoryEntry.Length;
+
+ record[offset++] = FieldTerminator;
+
+ Array.Copy(fieldData, 0, record, offset, fieldData.Length);
+
+ return record;
+ }
+
+ private static void WriteString(MemoryStream ms, string value)
+ {
+ ms.Write(Encoding.ASCII.GetBytes(value));
+ ms.WriteByte(UnitTerminator);
+ }
+
+ ///
+ /// Creates a self-contained temporary exchange set directory with a CATALOG.031 describing
+ /// the supplied (relative-path, content) cell files, computing each file's CRC and writing
+ /// it to the CRCS subfield unless an override is provided.
+ ///
+ private static string CreateExchangeSet(
+ IEnumerable<(string relativePath, byte[] content, string? crcOverride)> files)
+ {
+ var root = Path.Combine(Path.GetTempPath(), "s57-verify-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+
+ var records = new List();
+ uint rcid = 1;
+
+ foreach (var (relativePath, content, crcOverride) in files)
+ {
+ var fullPath = Path.Combine(root, Path.Combine(relativePath.Split('/')));
+ Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
+ File.WriteAllBytes(fullPath, content);
+
+ string crc = crcOverride ?? S57Crc32.Format(S57Crc32.Compute(content));
+ records.Add(CreateCatdRecord(rcid: rcid++, file: relativePath, lfil: relativePath, crcs: crc));
+ }
+
+ File.WriteAllBytes(Path.Combine(root, "CATALOG.031"), CreateCatalogDocument(records.ToArray()));
+ return root;
+ }
+
+ #endregion
+
+ #region CRC32 known-answer tests
+
+ [Fact]
+ public void Crc32_KnownAnswer_MatchesIeeeVector()
+ {
+ // "123456789" → 0xCBF43926 is the canonical CRC-32/ISO-HDLC check value.
+ var data = Encoding.ASCII.GetBytes("123456789");
+
+ uint crc = S57Crc32.Compute(data);
+
+ Assert.Equal(0xCBF43926u, crc);
+ Assert.Equal("CBF43926", S57Crc32.Format(crc));
+ }
+
+ [Fact]
+ public void Crc32_EmptyInput_IsZero()
+ {
+ Assert.Equal(0u, S57Crc32.Compute([]));
+ Assert.Equal("00000000", S57Crc32.Format(0u));
+ }
+
+ #endregion
+
+ #region Verifier tests
+
+ [Fact]
+ public async Task VerifyAsync_MatchingCrc_ReturnsOk()
+ {
+ var content = Encoding.ASCII.GetBytes("ENC CELL CONTENT");
+ var root = CreateExchangeSet([("US5WA51M/US5WA51M.000", content, null)]);
+
+ try
+ {
+ var catalog = S57CatalogReader.ReadFromFile(Path.Combine(root, "CATALOG.031"));
+ var result = await new S57ExchangeSetVerifier().VerifyAsync(root, catalog);
+
+ var file = Assert.Single(result.FileResults);
+ Assert.Equal(S57VerificationOutcome.Ok, file.ChecksumOutcome);
+ Assert.Equal(S57VerificationOutcome.NotSigned, file.SignatureOutcome);
+ Assert.Equal(file.ExpectedCrc, file.ActualCrc);
+ Assert.True(result.AllValid);
+ Assert.True(result.IsUnsigned);
+ Assert.False(result.HasChecksumMismatches);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task VerifyAsync_WrongCrc_ReturnsChecksumMismatch()
+ {
+ var content = Encoding.ASCII.GetBytes("ENC CELL CONTENT");
+ var root = CreateExchangeSet([("US5WA51M/US5WA51M.000", content, "DEADBEEF")]);
+
+ try
+ {
+ var catalog = S57CatalogReader.ReadFromFile(Path.Combine(root, "CATALOG.031"));
+ var result = await new S57ExchangeSetVerifier().VerifyAsync(root, catalog);
+
+ var file = Assert.Single(result.FileResults);
+ Assert.Equal(S57VerificationOutcome.ChecksumMismatch, file.ChecksumOutcome);
+ Assert.Equal("DEADBEEF", file.ExpectedCrc);
+ Assert.NotNull(file.ActualCrc);
+ Assert.NotEqual(file.ExpectedCrc, file.ActualCrc);
+ Assert.False(result.AllValid);
+ Assert.True(result.HasChecksumMismatches);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task VerifyAsync_EmptyCrc_ReturnsNoChecksum()
+ {
+ var content = Encoding.ASCII.GetBytes("CONTENT");
+ var root = CreateExchangeSet([("US5WA51M/US5WA51M.000", content, "")]);
+
+ try
+ {
+ var catalog = S57CatalogReader.ReadFromFile(Path.Combine(root, "CATALOG.031"));
+ var result = await new S57ExchangeSetVerifier().VerifyAsync(root, catalog);
+
+ var file = Assert.Single(result.FileResults);
+ Assert.Equal(S57VerificationOutcome.NoChecksum, file.ChecksumOutcome);
+ // A missing CRC is not a failure (CRCs are optional in S-57), so AllValid holds.
+ Assert.True(result.AllValid);
+ Assert.False(result.HasChecksumMismatches);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task VerifyAsync_MissingFile_ReturnsFileMissing()
+ {
+ var content = Encoding.ASCII.GetBytes("CONTENT");
+ var root = CreateExchangeSet([("US5WA51M/US5WA51M.000", content, null)]);
+
+ try
+ {
+ // Delete the cell file but leave the catalog (with its CRC) in place.
+ File.Delete(Path.Combine(root, "US5WA51M", "US5WA51M.000"));
+
+ var catalog = S57CatalogReader.ReadFromFile(Path.Combine(root, "CATALOG.031"));
+ var result = await new S57ExchangeSetVerifier().VerifyAsync(root, catalog);
+
+ var file = Assert.Single(result.FileResults);
+ Assert.Equal(S57VerificationOutcome.FileMissing, file.ChecksumOutcome);
+ Assert.True(result.HasMissingFiles);
+ Assert.False(result.AllValid);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task VerifyAsync_MultipleFiles_ReportsEachIndependently()
+ {
+ var good = Encoding.ASCII.GetBytes("GOOD CELL");
+ var bad = Encoding.ASCII.GetBytes("BAD CELL");
+ var root = CreateExchangeSet(
+ [
+ ("US5WA51M/US5WA51M.000", good, null),
+ ("US5WA52M/US5WA52M.000", bad, "00000001"),
+ ]);
+
+ try
+ {
+ var catalog = S57CatalogReader.ReadFromFile(Path.Combine(root, "CATALOG.031"));
+ var result = await new S57ExchangeSetVerifier().VerifyAsync(root, catalog);
+
+ Assert.Equal(2, result.FileResults.Length);
+ Assert.Equal(S57VerificationOutcome.Ok, result.FileResults[0].ChecksumOutcome);
+ Assert.Equal(S57VerificationOutcome.ChecksumMismatch, result.FileResults[1].ChecksumOutcome);
+ Assert.True(result.HasChecksumMismatches);
+ Assert.False(result.AllValid);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task VerifyAsync_LowercaseCrc_IsAcceptedCaseInsensitively()
+ {
+ var content = Encoding.ASCII.GetBytes("CASE INSENSITIVE");
+ string crc = S57Crc32.Format(S57Crc32.Compute(content)).ToLowerInvariant();
+ var root = CreateExchangeSet([("US5WA51M/US5WA51M.000", content, crc)]);
+
+ try
+ {
+ var catalog = S57CatalogReader.ReadFromFile(Path.Combine(root, "CATALOG.031"));
+ var result = await new S57ExchangeSetVerifier().VerifyAsync(root, catalog);
+
+ Assert.Equal(S57VerificationOutcome.Ok, Assert.Single(result.FileResults).ChecksumOutcome);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task S57ExchangeSet_VerifyAsync_ReadsCatalogAndVerifies()
+ {
+ var content = Encoding.ASCII.GetBytes("BASE CELL");
+ var root = CreateExchangeSet([("US5WA51M/US5WA51M.000", content, null)]);
+
+ try
+ {
+ var exchangeSet = S57ExchangeSetReader.Read(root);
+ var result = await exchangeSet.VerifyAsync(root);
+
+ Assert.True(result.AllValid);
+ Assert.Equal(S57VerificationOutcome.Ok, Assert.Single(result.FileResults).ChecksumOutcome);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task VerifyAsync_SignatureVerifierSupplied_OutcomeIsReported()
+ {
+ var content = Encoding.ASCII.GetBytes("SIGNED CELL");
+ var root = CreateExchangeSet([("US5WA51M/US5WA51M.000", content, null)]);
+
+ try
+ {
+ var catalog = S57CatalogReader.ReadFromFile(Path.Combine(root, "CATALOG.031"));
+ var verifier = new S57ExchangeSetVerifier(new StubSignatureVerifier(S57VerificationOutcome.SignatureInvalid));
+ var result = await verifier.VerifyAsync(root, catalog);
+
+ var file = Assert.Single(result.FileResults);
+ Assert.Equal(S57VerificationOutcome.Ok, file.ChecksumOutcome);
+ Assert.Equal(S57VerificationOutcome.SignatureInvalid, file.SignatureOutcome);
+ Assert.True(result.HasInvalidSignatures);
+ Assert.False(result.IsUnsigned);
+ Assert.False(result.AllValid);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ private sealed class StubSignatureVerifier(S57VerificationOutcome outcome) : IS63SignatureVerifier
+ {
+ public Task VerifySignatureAsync(
+ string filePath, S63TrustAnchorOptions trustAnchors, CancellationToken cancellationToken = default)
+ => Task.FromResult(outcome);
+ }
+
+ #endregion
+}