From 447bf84b131171d8670f4a4007e8dea32c3211e5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 04:04:58 -0400 Subject: [PATCH] feat(transport): ManifestBuilder + ManifestValidator with schema-version gating --- .../Serialization/ManifestBuilder.cs | 47 ++++++++ .../Serialization/ManifestValidator.cs | 50 +++++++++ .../Serialization/ManifestBuilderTests.cs | 103 ++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 src/ScadaLink.Transport/Serialization/ManifestBuilder.cs create mode 100644 src/ScadaLink.Transport/Serialization/ManifestValidator.cs create mode 100644 tests/ScadaLink.Transport.Tests/Serialization/ManifestBuilderTests.cs diff --git a/src/ScadaLink.Transport/Serialization/ManifestBuilder.cs b/src/ScadaLink.Transport/Serialization/ManifestBuilder.cs new file mode 100644 index 0000000..de60b0c --- /dev/null +++ b/src/ScadaLink.Transport/Serialization/ManifestBuilder.cs @@ -0,0 +1,47 @@ +using System.Security.Cryptography; +using ScadaLink.Commons.Types.Transport; + +namespace ScadaLink.Transport.Serialization; + +/// +/// Builds a for a freshly serialized bundle. +/// Stamps the current format/schema version, captures the SHA-256 hash of the +/// raw (post-encryption) content bytes, and copies through the supplied summary +/// and content entries verbatim. +/// +public sealed class ManifestBuilder +{ + public const int CurrentBundleFormatVersion = 1; + public const string CurrentSchemaVersion = "1.0"; + + public BundleManifest Build( + string sourceEnvironment, + string exportedBy, + string scadaLinkVersion, + EncryptionMetadata? encryption, + BundleSummary summary, + IReadOnlyList contents, + byte[] contentBytes) + { + ArgumentNullException.ThrowIfNull(sourceEnvironment); + ArgumentNullException.ThrowIfNull(exportedBy); + ArgumentNullException.ThrowIfNull(scadaLinkVersion); + ArgumentNullException.ThrowIfNull(summary); + ArgumentNullException.ThrowIfNull(contents); + ArgumentNullException.ThrowIfNull(contentBytes); + + var contentHash = "sha256:" + Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant(); + + return new BundleManifest( + BundleFormatVersion: CurrentBundleFormatVersion, + SchemaVersion: CurrentSchemaVersion, + CreatedAtUtc: DateTimeOffset.UtcNow, + SourceEnvironment: sourceEnvironment, + ExportedBy: exportedBy, + ScadaLinkVersion: scadaLinkVersion, + ContentHash: contentHash, + Encryption: encryption, + Summary: summary, + Contents: contents); + } +} diff --git a/src/ScadaLink.Transport/Serialization/ManifestValidator.cs b/src/ScadaLink.Transport/Serialization/ManifestValidator.cs new file mode 100644 index 0000000..4273dde --- /dev/null +++ b/src/ScadaLink.Transport/Serialization/ManifestValidator.cs @@ -0,0 +1,50 @@ +using System.Security.Cryptography; +using ScadaLink.Commons.Types.Transport; + +namespace ScadaLink.Transport.Serialization; + +/// +/// Outcome of validating a against the supplied +/// raw content bytes. Distinct values let the importer surface a precise +/// rejection reason to the operator. +/// +public enum ManifestValidationResult +{ + Ok, + UnsupportedFormatVersion, + ContentHashMismatch, + MalformedManifest +} + +/// +/// Inspects a deserialized manifest plus the raw content bytes recovered from +/// the bundle ZIP and reports the first integrity failure (or ). +/// +public sealed class ManifestValidator +{ + public ManifestValidationResult Validate(BundleManifest manifest, byte[] contentBytes) + { + if (manifest is null || contentBytes is null) + { + return ManifestValidationResult.MalformedManifest; + } + + if (string.IsNullOrEmpty(manifest.SourceEnvironment) || manifest.Contents is null) + { + return ManifestValidationResult.MalformedManifest; + } + + if (manifest.BundleFormatVersion != ManifestBuilder.CurrentBundleFormatVersion) + { + return ManifestValidationResult.UnsupportedFormatVersion; + } + + var expected = "sha256:" + Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant(); + if (!string.Equals(expected, manifest.ContentHash, StringComparison.Ordinal)) + { + return ManifestValidationResult.ContentHashMismatch; + } + + return ManifestValidationResult.Ok; + } +} diff --git a/tests/ScadaLink.Transport.Tests/Serialization/ManifestBuilderTests.cs b/tests/ScadaLink.Transport.Tests/Serialization/ManifestBuilderTests.cs new file mode 100644 index 0000000..46a6e55 --- /dev/null +++ b/tests/ScadaLink.Transport.Tests/Serialization/ManifestBuilderTests.cs @@ -0,0 +1,103 @@ +using System.Security.Cryptography; +using System.Text; +using ScadaLink.Commons.Types.Transport; +using ScadaLink.Transport.Serialization; + +namespace ScadaLink.Transport.Tests.Serialization; + +public sealed class ManifestBuilderTests +{ + private static BundleSummary EmptySummary => new(0, 0, 0, 0, 0, 0, 0, 0, 0); + private static IReadOnlyList NoContents => Array.Empty(); + + [Fact] + public void Build_populates_summary_from_contents() + { + var sut = new ManifestBuilder(); + var summary = new BundleSummary(2, 1, 0, 0, 0, 0, 0, 0, 0); + var contents = new[] + { + new ManifestContentEntry("Template", "T1", 1, Array.Empty()), + new ManifestContentEntry("Template", "T2", 1, new[] { "T1" }), + new ManifestContentEntry("TemplateFolder", "F1", 1, Array.Empty()), + }; + + var manifest = sut.Build("env", "user", "1.0.0", encryption: null, summary, contents, contentBytes: new byte[] { 1, 2, 3 }); + + Assert.Equal(summary, manifest.Summary); + Assert.Equal(contents, manifest.Contents); + Assert.Equal("env", manifest.SourceEnvironment); + Assert.Equal("user", manifest.ExportedBy); + Assert.Equal("1.0.0", manifest.ScadaLinkVersion); + Assert.Equal(1, manifest.BundleFormatVersion); + Assert.Equal("1.0", manifest.SchemaVersion); + Assert.Null(manifest.Encryption); + } + + [Fact] + public void Build_computes_content_hash_with_sha256_prefix() + { + var sut = new ManifestBuilder(); + var bytes = Encoding.UTF8.GetBytes("known-content"); + var expectedHashHex = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + var manifest = sut.Build("env", "user", "v", encryption: null, EmptySummary, NoContents, bytes); + + Assert.Equal("sha256:" + expectedHashHex, manifest.ContentHash); + } + + [Fact] + public void Validate_rejects_unsupported_bundleFormatVersion() + { + var bytes = new byte[] { 1, 2, 3 }; + var hash = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + var manifest = new BundleManifest( + BundleFormatVersion: 999, + SchemaVersion: "1.0", + CreatedAtUtc: DateTimeOffset.UtcNow, + SourceEnvironment: "env", + ExportedBy: "u", + ScadaLinkVersion: "v", + ContentHash: hash, + Encryption: null, + Summary: EmptySummary, + Contents: NoContents); + + var result = new ManifestValidator().Validate(manifest, bytes); + + Assert.Equal(ManifestValidationResult.UnsupportedFormatVersion, result); + } + + [Fact] + public void Validate_rejects_when_contentHash_mismatch() + { + var bytes = new byte[] { 1, 2, 3 }; + var manifest = new BundleManifest( + BundleFormatVersion: 1, + SchemaVersion: "1.0", + CreatedAtUtc: DateTimeOffset.UtcNow, + SourceEnvironment: "env", + ExportedBy: "u", + ScadaLinkVersion: "v", + ContentHash: "sha256:deadbeef", + Encryption: null, + Summary: EmptySummary, + Contents: NoContents); + + var result = new ManifestValidator().Validate(manifest, bytes); + + Assert.Equal(ManifestValidationResult.ContentHashMismatch, result); + } + + [Fact] + public void Validate_accepts_well_formed_v1_manifest() + { + var sut = new ManifestBuilder(); + var bytes = Encoding.UTF8.GetBytes("hello"); + var manifest = sut.Build("env", "u", "v", encryption: null, EmptySummary, NoContents, bytes); + + var result = new ManifestValidator().Validate(manifest, bytes); + + Assert.Equal(ManifestValidationResult.Ok, result); + } +}