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