diff --git a/src/ScadaLink.Transport/Serialization/BundleSerializer.cs b/src/ScadaLink.Transport/Serialization/BundleSerializer.cs new file mode 100644 index 0000000..d5d42a7 --- /dev/null +++ b/src/ScadaLink.Transport/Serialization/BundleSerializer.cs @@ -0,0 +1,178 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; +using ScadaLink.Commons.Types.Transport; +using ScadaLink.Transport.Encryption; + +namespace ScadaLink.Transport.Serialization; + +/// +/// Packs + into a +/// ZIP archive (manifest.json always, plus either content.json +/// or content.enc) and reverses the operation on read. +/// +/// When packing with a passphrase, the caller must have already encrypted the +/// content bytes upstream and built a manifest whose +/// + ContentHash describe those exact bytes. uses the +/// manifest as source of truth: it re-serializes the content (or re-encrypts +/// with the manifest's salt/iv when needed) so the zip stays internally +/// consistent. The simpler usage — and the one BundleExporter follows — is to +/// pre-encrypt, hash, build manifest, then call Pack which trusts the manifest +/// + re-encrypts to a fresh IV and rebuilds the manifest internally. +/// +public sealed class BundleSerializer +{ + private const string ManifestEntryName = "manifest.json"; + private const string ContentJsonEntryName = "content.json"; + private const string ContentEncEntryName = "content.enc"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + /// + /// Serializes the bundle content to its canonical JSON byte form. Exposed + /// so callers can compute the content hash (or pre-encrypt) before they + /// build the manifest. + /// + public byte[] SerializeContentBytes(BundleContentDto content) + { + ArgumentNullException.ThrowIfNull(content); + return JsonSerializer.SerializeToUtf8Bytes(content, JsonOptions); + } + + public Stream Pack( + BundleContentDto content, + BundleManifest manifest, + string? passphrase, + BundleSecretEncryptor? encryptor) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(manifest); + + if (passphrase is not null && encryptor is null) + { + throw new ArgumentException("Encryptor is required when a passphrase is supplied.", nameof(encryptor)); + } + if (passphrase is not null && manifest.Encryption is null) + { + throw new ArgumentException("Manifest must carry EncryptionMetadata when packing an encrypted bundle.", nameof(manifest)); + } + + var contentBytes = SerializeContentBytes(content); + byte[] payload; + string payloadEntryName; + BundleManifest finalManifest; + + if (passphrase is not null && encryptor is not null && manifest.Encryption is not null) + { + // Fresh encryption — re-derives salt/iv. The manifest passed in described + // the upstream encrypt; we honour the requested algorithm/kdf/iteration + // count but rebuild ContentHash + Encryption fields against the bytes we + // actually write. The non-encryption manifest fields (source env, exported + // by, summary, contents, version) are preserved verbatim. + var (cipher, freshMeta) = encryptor.Encrypt(contentBytes, passphrase, manifest.Encryption.Iterations); + payload = cipher; + payloadEntryName = ContentEncEntryName; + finalManifest = manifest with + { + Encryption = freshMeta, + ContentHash = "sha256:" + Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(cipher)).ToLowerInvariant(), + }; + } + else + { + payload = contentBytes; + payloadEntryName = ContentJsonEntryName; + // Plaintext path: trust the supplied manifest's ContentHash. The caller + // built it via ManifestBuilder against the same SerializeContentBytes + // output (JSON is deterministic for a given DTO + JsonOptions). + finalManifest = manifest; + } + + var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(finalManifest, JsonOptions); + var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteEntry(archive, ManifestEntryName, manifestBytes); + WriteEntry(archive, payloadEntryName, payload); + } + + ms.Position = 0; + return ms; + } + + public BundleManifest ReadManifest(Stream zipStream) + { + ArgumentNullException.ThrowIfNull(zipStream); + + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true); + var entry = archive.GetEntry(ManifestEntryName) + ?? throw new InvalidDataException("Bundle is missing manifest.json."); + + using var es = entry.Open(); + var manifest = JsonSerializer.Deserialize(es, JsonOptions) + ?? throw new InvalidDataException("manifest.json deserialized to null."); + return manifest; + } + + /// + /// Returns the raw bytes from content.enc if present, otherwise from + /// content.json. The caller decrypts + JSON-deserializes via + /// . + /// + public byte[] ReadContentBytes(Stream zipStream, BundleManifest manifest) + { + ArgumentNullException.ThrowIfNull(zipStream); + ArgumentNullException.ThrowIfNull(manifest); + + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true); + // Prefer the encrypted variant when both are somehow present. + var entry = archive.GetEntry(ContentEncEntryName) ?? archive.GetEntry(ContentJsonEntryName) + ?? throw new InvalidDataException("Bundle is missing both content.enc and content.json."); + + using var es = entry.Open(); + using var ms = new MemoryStream(); + es.CopyTo(ms); + return ms.ToArray(); + } + + public BundleContentDto UnpackContent( + byte[] contentBytes, + BundleManifest manifest, + string? passphrase, + BundleSecretEncryptor? encryptor) + { + ArgumentNullException.ThrowIfNull(contentBytes); + ArgumentNullException.ThrowIfNull(manifest); + + byte[] plaintext; + if (manifest.Encryption is not null) + { + if (passphrase is null || encryptor is null) + { + throw new ArgumentException("Encrypted bundle requires both passphrase and encryptor."); + } + plaintext = encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase); + } + else + { + plaintext = contentBytes; + } + + var content = JsonSerializer.Deserialize(plaintext, JsonOptions) + ?? throw new InvalidDataException("content payload deserialized to null."); + return content; + } + + private static void WriteEntry(ZipArchive archive, string entryName, byte[] payload) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var es = entry.Open(); + es.Write(payload, 0, payload.Length); + } +} diff --git a/tests/ScadaLink.Transport.Tests/Serialization/BundleSerializerTests.cs b/tests/ScadaLink.Transport.Tests/Serialization/BundleSerializerTests.cs new file mode 100644 index 0000000..c7b2345 --- /dev/null +++ b/tests/ScadaLink.Transport.Tests/Serialization/BundleSerializerTests.cs @@ -0,0 +1,126 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using ScadaLink.Commons.Types.Transport; +using ScadaLink.Transport.Encryption; +using ScadaLink.Transport.Serialization; + +namespace ScadaLink.Transport.Tests.Serialization; + +public sealed class BundleSerializerTests +{ + private const int TestIterations = 10_000; + + private static BundleContentDto SampleContent() => new( + TemplateFolders: new[] { new TemplateFolderDto("Root", ParentName: null, SortOrder: 0) }, + Templates: Array.Empty(), + SharedScripts: new[] { new SharedScriptDto("util", "return 42;", ParameterDefinitions: null, ReturnDefinition: null) }, + ExternalSystems: Array.Empty(), + DatabaseConnections: Array.Empty(), + NotificationLists: Array.Empty(), + SmtpConfigs: Array.Empty(), + ApiKeys: Array.Empty(), + ApiMethods: Array.Empty()); + + private static BundleManifest BuildManifestFor(byte[] contentBytes, EncryptionMetadata? encryption = null) => + new ManifestBuilder().Build( + sourceEnvironment: "test-env", + exportedBy: "tester", + scadaLinkVersion: "1.0.0", + encryption: encryption, + summary: new BundleSummary(0, 1, 1, 0, 0, 0, 0, 0, 0), + contents: Array.Empty(), + contentBytes: contentBytes); + + [Fact] + public void Pack_emits_manifest_and_content_json_when_no_passphrase() + { + var sut = new BundleSerializer(); + var content = SampleContent(); + var contentJson = sut.SerializeContentBytes(content); + var manifest = BuildManifestFor(contentJson); + + using var stream = sut.Pack(content, manifest, passphrase: null, encryptor: null); + + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + Assert.NotNull(archive.GetEntry("manifest.json")); + Assert.NotNull(archive.GetEntry("content.json")); + Assert.Null(archive.GetEntry("content.enc")); + } + + [Fact] + public void Pack_emits_manifest_and_content_enc_when_passphrase_supplied() + { + var sut = new BundleSerializer(); + var encryptor = new BundleSecretEncryptor(); + var content = SampleContent(); + var contentJson = sut.SerializeContentBytes(content); + var (cipher, meta) = encryptor.Encrypt(contentJson, "pass", TestIterations); + var manifest = BuildManifestFor(cipher, encryption: meta); + + using var stream = sut.Pack(content, manifest, passphrase: "pass", encryptor: encryptor); + + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + Assert.NotNull(archive.GetEntry("manifest.json")); + Assert.Null(archive.GetEntry("content.json")); + Assert.NotNull(archive.GetEntry("content.enc")); + } + + [Fact] + public void Roundtrip_through_temp_stream_recovers_identical_content() + { + var sut = new BundleSerializer(); + var content = SampleContent(); + var contentJson = sut.SerializeContentBytes(content); + var manifest = BuildManifestFor(contentJson); + + using var stream = sut.Pack(content, manifest, passphrase: null, encryptor: null); + + stream.Position = 0; + var readManifest = sut.ReadManifest(stream); + stream.Position = 0; + var contentBytes = sut.ReadContentBytes(stream, readManifest); + var unpacked = sut.UnpackContent(contentBytes, readManifest, passphrase: null, encryptor: null); + + Assert.Equal(manifest.ContentHash, readManifest.ContentHash); + Assert.Single(unpacked.TemplateFolders); + Assert.Equal("Root", unpacked.TemplateFolders[0].Name); + var shared = Assert.Single(unpacked.SharedScripts); + Assert.Equal("util", shared.Name); + Assert.Equal("return 42;", shared.Code); + } + + [Fact] + public void ReadManifest_throws_when_zip_missing_manifest_json() + { + var sut = new BundleSerializer(); + using var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry("garbage.txt"); + using var es = entry.Open(); + es.Write(new byte[] { 1, 2, 3 }); + } + ms.Position = 0; + + Assert.Throws(() => sut.ReadManifest(ms)); + } + + [Fact] + public void UnpackContent_throws_CryptographicException_on_wrong_passphrase() + { + var sut = new BundleSerializer(); + var encryptor = new BundleSecretEncryptor(); + var content = SampleContent(); + var contentJson = sut.SerializeContentBytes(content); + var (cipher, meta) = encryptor.Encrypt(contentJson, "right", TestIterations); + var manifest = BuildManifestFor(cipher, encryption: meta); + + using var stream = sut.Pack(content, manifest, passphrase: "right", encryptor: encryptor); + stream.Position = 0; + var readManifest = sut.ReadManifest(stream); + stream.Position = 0; + var contentBytes = sut.ReadContentBytes(stream, readManifest); + + Assert.ThrowsAny(() => sut.UnpackContent(contentBytes, readManifest, "wrong", encryptor)); + } +}