From ed00f7f9970f1643825353724b12b0aac9892b18 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Thu, 11 Jun 2026 14:05:06 -0700 Subject: [PATCH 1/2] Add S-57 exchange set integrity verification (CATALOG.031 CRC + S-63 seam) Implements GitHub issue #6. Adds a non-throwing, opt-in exchange-set verifier under EncDotNet.S57/ExchangeSets/ that mirrors the S-100 sibling's verification API: - S57VerificationOutcome enum (S-100 member names + NoChecksum / ChecksumMismatch). - S57FileVerificationResult tracking checksum and signature outcomes as independent dimensions; S57ExchangeSetVerificationResult aggregate. - IS57ExchangeSetVerifier / S57ExchangeSetVerifier validating each CATD CRCS checksum against the on-disk file via an internal CRC-32 helper. - S-63 signature SEAMS only: S63TrustAnchorOptions + IS63SignatureVerifier (SignatureOutcome defaults to NotSigned), gated on future S-63 support. - S57ExchangeSet.VerifyAsync(rootPath, ...) entry point. - xUnit tests with synthetic CATALOG.031 fixtures (no real ENC data) and a CRC-32 known-answer test; README updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/EncDotNet.S57/EncDotNet.S57.csproj | 3 + .../ExchangeSets/IS57ExchangeSetVerifier.cs | 41 ++ .../ExchangeSets/IS63SignatureVerifier.cs | 34 ++ src/EncDotNet.S57/ExchangeSets/S57Crc32.cs | 91 ++++ .../ExchangeSets/S57ExchangeSet.cs | 31 ++ .../S57ExchangeSetVerificationResult.cs | 38 ++ .../ExchangeSets/S57ExchangeSetVerifier.cs | 175 +++++++ .../ExchangeSets/S57FileVerificationResult.cs | 47 ++ .../ExchangeSets/S57VerificationOutcome.cs | 51 +++ .../ExchangeSets/S63TrustAnchorOptions.cs | 32 ++ src/EncDotNet.S57/README.md | 43 ++ .../S57ExchangeSetVerifierTests.cs | 430 ++++++++++++++++++ 12 files changed, 1016 insertions(+) create mode 100644 src/EncDotNet.S57/ExchangeSets/IS57ExchangeSetVerifier.cs create mode 100644 src/EncDotNet.S57/ExchangeSets/IS63SignatureVerifier.cs create mode 100644 src/EncDotNet.S57/ExchangeSets/S57Crc32.cs create mode 100644 src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerificationResult.cs create mode 100644 src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerifier.cs create mode 100644 src/EncDotNet.S57/ExchangeSets/S57FileVerificationResult.cs create mode 100644 src/EncDotNet.S57/ExchangeSets/S57VerificationOutcome.cs create mode 100644 src/EncDotNet.S57/ExchangeSets/S63TrustAnchorOptions.cs create mode 100644 tests/EndDotNet.UnitTests/S57ExchangeSetVerifierTests.cs 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..7229608 --- /dev/null +++ b/src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerificationResult.cs @@ -0,0 +1,38 @@ +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 every file verified successfully: each file's checksum + /// is and its signature is either + /// or . + /// + public bool AllValid => FileResults.All(r => + r.ChecksumOutcome == S57VerificationOutcome.Ok + && 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..e27ab37 --- /dev/null +++ b/tests/EndDotNet.UnitTests/S57ExchangeSetVerifierTests.cs @@ -0,0 +1,430 @@ +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); + Assert.False(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 +} From 3f062dba272b36fbb1909b531d12a5ff5a14ef9d Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Thu, 11 Jun 2026 14:42:17 -0700 Subject: [PATCH 2/2] Treat NoChecksum as non-failing in AllValid; confirm CRC byte-order vs real ENC Validated the verifier against a real NOAA S-57 exchange set (US5WA70M): both declared CRCS values (FD6C39B8, A8292AA7) matched the computed big-endian uppercase-hex IEEE CRC-32 exactly, empirically confirming the byte-order convention pinned by the known-answer test. Refines S57ExchangeSetVerificationResult.AllValid so a missing CRC (NoChecksum) is not counted as a failure -- CRCs are optional in S-57 and the CATALOG.031 self-reference legitimately has none -- while ChecksumMismatch/FileMissing/Error and invalid signatures still fail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ExchangeSets/S57ExchangeSetVerificationResult.cs | 10 ++++++---- .../EndDotNet.UnitTests/S57ExchangeSetVerifierTests.cs | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerificationResult.cs b/src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerificationResult.cs index 7229608..486d5e5 100644 --- a/src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerificationResult.cs +++ b/src/EncDotNet.S57/ExchangeSets/S57ExchangeSetVerificationResult.cs @@ -11,12 +11,14 @@ public sealed class S57ExchangeSetVerificationResult public required ImmutableArray FileResults { get; init; } /// - /// Gets a value indicating whether every file verified successfully: each file's checksum - /// is and its signature is either - /// or . + /// 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 == S57VerificationOutcome.Ok + 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. diff --git a/tests/EndDotNet.UnitTests/S57ExchangeSetVerifierTests.cs b/tests/EndDotNet.UnitTests/S57ExchangeSetVerifierTests.cs index e27ab37..33fd8a8 100644 --- a/tests/EndDotNet.UnitTests/S57ExchangeSetVerifierTests.cs +++ b/tests/EndDotNet.UnitTests/S57ExchangeSetVerifierTests.cs @@ -292,7 +292,8 @@ public async Task VerifyAsync_EmptyCrc_ReturnsNoChecksum() var file = Assert.Single(result.FileResults); Assert.Equal(S57VerificationOutcome.NoChecksum, file.ChecksumOutcome); - Assert.False(result.AllValid); + // 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