fix(transport): serialise the export content once and pack those bytes (review 2026-10-21 04/P8a)
This commit is contained in:
@@ -159,6 +159,10 @@ public sealed class BundleExporter : IBundleExporter
|
|||||||
SaltB64: string.Empty,
|
SaltB64: string.Empty,
|
||||||
IvB64: string.Empty);
|
IvB64: string.Empty);
|
||||||
|
|
||||||
|
// Review 2026-10-21 04/P8a: serialise the content graph ONCE — the same bytes
|
||||||
|
// seed the manifest hash and are what Pack writes (or encrypts).
|
||||||
|
var contentBytes = _bundleSerializer.SerializeContentBytes(contentDto);
|
||||||
|
|
||||||
var templateManifest = _manifestBuilder.Build(
|
var templateManifest = _manifestBuilder.Build(
|
||||||
sourceEnvironment: sourceEnvironment,
|
sourceEnvironment: sourceEnvironment,
|
||||||
exportedBy: user,
|
exportedBy: user,
|
||||||
@@ -170,11 +174,11 @@ public sealed class BundleExporter : IBundleExporter
|
|||||||
// (either fresh ciphertext or the JSON it just serialized), so the
|
// (either fresh ciphertext or the JSON it just serialized), so the
|
||||||
// upstream hash here only matters for the plaintext path — Pack
|
// upstream hash here only matters for the plaintext path — Pack
|
||||||
// trusts the supplied hash and the serializer is deterministic.
|
// trusts the supplied hash and the serializer is deterministic.
|
||||||
contentBytes: _bundleSerializer.SerializeContentBytes(contentDto));
|
contentBytes: contentBytes);
|
||||||
|
|
||||||
// 5. Pack the zip; with a passphrase, Pack re-encrypts and re-stamps
|
// 5. Pack the zip; with a passphrase, Pack re-encrypts and re-stamps
|
||||||
// EncryptionMetadata (fresh salt/iv) + ContentHash.
|
// EncryptionMetadata (fresh salt/iv) + ContentHash.
|
||||||
var zipStream = _bundleSerializer.Pack(contentDto, templateManifest, passphrase, _encryptor);
|
var zipStream = _bundleSerializer.PackContentBytes(contentBytes, templateManifest, passphrase, _encryptor);
|
||||||
|
|
||||||
// 6. Audit — entityId is the bundle's overall SHA-256 over the zip bytes
|
// 6. Audit — entityId is the bundle's overall SHA-256 over the zip bytes
|
||||||
// (opaque to caller but uniquely identifies this export). Action
|
// (opaque to caller but uniquely identifies this export). Action
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ public sealed class BundleSerializer
|
|||||||
BundleSecretEncryptor? encryptor)
|
BundleSecretEncryptor? encryptor)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(content);
|
ArgumentNullException.ThrowIfNull(content);
|
||||||
|
return PackContentBytes(SerializeContentBytes(content), manifest, passphrase, encryptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Packs already-serialised content (the output of <see cref="SerializeContentBytes"/>)
|
||||||
|
/// and the manifest into a ZIP stream, optionally encrypting the content. Lets a caller
|
||||||
|
/// that has already serialised the DTO — e.g. to hash it for the manifest — pack those
|
||||||
|
/// same bytes instead of serialising the whole graph a second time (review 2026-10-21
|
||||||
|
/// 04/P8a).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="contentBytes">The serialised bundle content JSON.</param>
|
||||||
|
/// <param name="manifest">The bundle manifest describing the content and encryption metadata.</param>
|
||||||
|
/// <param name="passphrase">Passphrase used for encryption; null produces a plaintext bundle.</param>
|
||||||
|
/// <param name="encryptor">Encryptor required when <paramref name="passphrase"/> is supplied.</param>
|
||||||
|
/// <returns>A seeked-to-start <see cref="MemoryStream"/> containing the bundle ZIP archive.</returns>
|
||||||
|
public Stream PackContentBytes(
|
||||||
|
byte[] contentBytes,
|
||||||
|
BundleManifest manifest,
|
||||||
|
string? passphrase,
|
||||||
|
BundleSecretEncryptor? encryptor)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(contentBytes);
|
||||||
ArgumentNullException.ThrowIfNull(manifest);
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
|
|
||||||
if (passphrase is not null && encryptor is null)
|
if (passphrase is not null && encryptor is null)
|
||||||
@@ -68,7 +90,6 @@ public sealed class BundleSerializer
|
|||||||
throw new ArgumentException("Manifest must carry EncryptionMetadata when packing an encrypted bundle.", nameof(manifest));
|
throw new ArgumentException("Manifest must carry EncryptionMetadata when packing an encrypted bundle.", nameof(manifest));
|
||||||
}
|
}
|
||||||
|
|
||||||
var contentBytes = SerializeContentBytes(content);
|
|
||||||
byte[] payload;
|
byte[] payload;
|
||||||
string payloadEntryName;
|
string payloadEntryName;
|
||||||
BundleManifest finalManifest;
|
BundleManifest finalManifest;
|
||||||
|
|||||||
@@ -50,6 +50,38 @@ public sealed class BundleSerializerTests
|
|||||||
Assert.Null(archive.GetEntry("content.enc"));
|
Assert.Null(archive.GetEntry("content.enc"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review 2026-10-21 04/P8a: the exporter serialises the content DTO once and packs
|
||||||
|
/// those bytes — the pre-serialised overload writes them verbatim as the plaintext
|
||||||
|
/// payload and they round-trip, encrypted or not.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="passphrase">Null for a plaintext bundle, otherwise the passphrase.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("pass")]
|
||||||
|
public void PackContentBytes_packs_preserialised_content_verbatim(string? passphrase)
|
||||||
|
{
|
||||||
|
var sut = new BundleSerializer();
|
||||||
|
var encryptor = new BundleSecretEncryptor();
|
||||||
|
var contentJson = sut.SerializeContentBytes(SampleContent());
|
||||||
|
var manifest = passphrase is null
|
||||||
|
? BuildManifestFor(contentJson)
|
||||||
|
: BuildManifestFor(contentJson, encryption: new EncryptionMetadata(
|
||||||
|
"AES-256-GCM", "PBKDF2-SHA256", TestIterations, string.Empty, string.Empty));
|
||||||
|
|
||||||
|
using var stream = sut.PackContentBytes(contentJson, manifest, passphrase, encryptor);
|
||||||
|
|
||||||
|
var readManifest = sut.ReadManifest(stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
var payload = sut.ReadContentBytes(stream, readManifest);
|
||||||
|
if (passphrase is null)
|
||||||
|
{
|
||||||
|
Assert.Equal(contentJson, payload);
|
||||||
|
}
|
||||||
|
var unpacked = sut.UnpackContent(payload, readManifest, passphrase, encryptor);
|
||||||
|
Assert.Equal("util", Assert.Single(unpacked.SharedScripts).Name);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that packing with a passphrase emits a manifest and encrypted content.</summary>
|
/// <summary>Verifies that packing with a passphrase emits a manifest and encrypted content.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Pack_emits_manifest_and_content_enc_when_passphrase_supplied()
|
public void Pack_emits_manifest_and_content_enc_when_passphrase_supplied()
|
||||||
|
|||||||
Reference in New Issue
Block a user