fix(runtime): parallel-safe decompression limit; tighten ParsedDeploymentArtifact surface
This commit is contained in:
@@ -114,7 +114,8 @@ public static class DeploymentArtifact
|
||||
/// <exception cref="JsonException">The blob is not valid JSON, is not decompressible, or inflates past
|
||||
/// <see cref="MaxDecompressedBytes"/>. Unlike the static wrappers — which own the lenient "malformed ⇒
|
||||
/// empty result" contract — opening is explicit about unusable bytes.</exception>
|
||||
public static ParsedDeploymentArtifact Open(byte[] blob) => new(JsonDocument.Parse(Inflate(blob)));
|
||||
internal static ParsedDeploymentArtifact Open(byte[] blob) =>
|
||||
new(JsonDocument.Parse(Inflate(blob, MaxDecompressedBytes)));
|
||||
|
||||
/// <summary>
|
||||
/// Hard ceiling on the decompressed size of an artifact blob (512 MiB). A corrupt or hostile blob
|
||||
@@ -123,10 +124,13 @@ public static class DeploymentArtifact
|
||||
/// ones are single-digit MB before compression.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mutable and internal purely so tests can lower it: proving the guard fires with the production
|
||||
/// value would mean crafting and inflating half a gigabyte per test run. Production never writes it.
|
||||
/// A <see langword="const"/> deliberately: it used to be a mutable static that the size-guard test
|
||||
/// lowered and restored. xUnit runs test CLASSES in parallel, so that window was visible to every
|
||||
/// sibling class parsing a >4 KB artifact concurrently — a spurious "refusing to inflate" failure
|
||||
/// with no relation to the test that caused it. The limit is a <see cref="Inflate"/> parameter
|
||||
/// instead, so the guard is provable at any ceiling without any process-wide mutation.
|
||||
/// </remarks>
|
||||
internal static int MaxDecompressedBytes = 512 * 1024 * 1024;
|
||||
internal const int MaxDecompressedBytes = 512 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Return the artifact's JSON bytes, decompressing when the blob carries the gzip magic
|
||||
@@ -150,13 +154,16 @@ public static class DeploymentArtifact
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="blob">The stored/wire artifact bytes.</param>
|
||||
/// <param name="maxBytes">Ceiling on the inflated size. Production always passes
|
||||
/// <see cref="MaxDecompressedBytes"/> via <see cref="Open"/>; the parameter exists so the guard can be
|
||||
/// proved at a small ceiling without mutating process-wide state that parallel test classes share.</param>
|
||||
/// <returns>The JSON bytes to parse.</returns>
|
||||
/// <exception cref="JsonException">The gzip stream is corrupt or inflates past <see cref="MaxDecompressedBytes"/>.</exception>
|
||||
internal static byte[] Inflate(byte[] blob)
|
||||
/// <exception cref="JsonException">The gzip stream is corrupt or inflates past <paramref name="maxBytes"/>.</exception>
|
||||
internal static byte[] Inflate(byte[] blob, int maxBytes)
|
||||
{
|
||||
if (blob.Length < 2 || blob[0] != 0x1F || blob[1] != 0x8B) return blob;
|
||||
|
||||
var limit = MaxDecompressedBytes;
|
||||
var limit = maxBytes;
|
||||
try
|
||||
{
|
||||
using var input = new MemoryStream(blob, writable: false);
|
||||
@@ -340,8 +347,15 @@ public static class DeploymentArtifact
|
||||
/// Not thread-safe: the per-node <see cref="ResolveClusterScope(string)"/> memo is an unsynchronised
|
||||
/// dictionary. An apply resolves its consumers sequentially, which is the only intended use.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Internal on purpose.</b> Every consumer is inside this assembly (the apply paths in
|
||||
/// <c>DriverHostActor</c> and <c>OpcUaPublishActor</c>, plus the tests via <c>InternalsVisibleTo</c>),
|
||||
/// and the lenient static wrappers on <see cref="DeploymentArtifact"/> are the cross-assembly door.
|
||||
/// Keeping the disposable, non-thread-safe, document-owning type unexported is what makes the
|
||||
/// "results survive disposal" contract enforceable rather than merely documented.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ParsedDeploymentArtifact : IDisposable
|
||||
internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
{
|
||||
private readonly JsonDocument _document;
|
||||
|
||||
@@ -357,8 +371,15 @@ public sealed class ParsedDeploymentArtifact : IDisposable
|
||||
/// <param name="document">The parsed artifact document, disposed with this instance.</param>
|
||||
internal ParsedDeploymentArtifact(JsonDocument document) => _document = document;
|
||||
|
||||
/// <summary>The artifact's root JSON object.</summary>
|
||||
public JsonElement Root => _document.RootElement;
|
||||
/// <summary>
|
||||
/// The artifact's root JSON object. <b>Valid only until <see cref="Dispose"/> — do not retain it,
|
||||
/// and do not hand it outside this type.</b> Unlike everything the parse methods return (fully
|
||||
/// materialised records, lists and strings that hold no reference back into the document), a
|
||||
/// <see cref="JsonElement"/> is a live view over the disposed-with-us <see cref="JsonDocument"/>:
|
||||
/// reading one after disposal throws, or worse, reads a recycled pooled buffer. Internal so that
|
||||
/// escape hatch cannot be taken from outside the assembly.
|
||||
/// </summary>
|
||||
internal JsonElement Root => _document.RootElement;
|
||||
|
||||
/// <summary>Releases the underlying <see cref="JsonDocument"/>. Idempotent.</summary>
|
||||
public void Dispose()
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- DeploymentArtifact.MaxDecompressedBytes is internal + mutable so the artifact-decompression
|
||||
guard can be exercised without inflating half a gigabyte per test run. -->
|
||||
<!-- ParsedDeploymentArtifact (and DeploymentArtifact.Open / .Inflate) are internal: every consumer
|
||||
is in this assembly, and the type is a disposable, non-thread-safe wrapper over a live
|
||||
JsonDocument that must not escape it. The tests drive that seam directly — including
|
||||
Inflate(blob, maxBytes), which takes the decompression ceiling as a parameter so the
|
||||
artifact-bomb guard is provable without inflating half a gigabyte per test run. -->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Runtime.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+26
-23
@@ -410,33 +410,36 @@ public sealed class ParsedDeploymentArtifactTests
|
||||
// A hostile/corrupt blob must not be able to OOM the node: gzip of a highly repetitive payload
|
||||
// reaches ~1000:1, so a few-hundred-KB artifact row could inflate to hundreds of MB. The production
|
||||
// limit is 512 MB, which no honest artifact approaches — crafting a real 512 MB expansion in a unit
|
||||
// test would burn seconds and gigabytes, so the limit is an internal seam this test lowers instead.
|
||||
var original = DeploymentArtifact.MaxDecompressedBytes;
|
||||
try
|
||||
{
|
||||
var payload = new byte[64 * 1024]; // all zeroes: compresses to a few dozen bytes
|
||||
var bomb = Gzip(payload);
|
||||
bomb.Length.ShouldBeLessThan(1024); // the small-in / huge-out shape the guard exists for
|
||||
// test would burn seconds and gigabytes, so the ceiling is a parameter on the internal Inflate seam
|
||||
// and this test passes a small one.
|
||||
//
|
||||
// It used to lower a mutable DeploymentArtifact.MaxDecompressedBytes static and restore it in a
|
||||
// finally. xUnit runs test CLASSES in parallel, so that window was visible to every sibling class
|
||||
// parsing a >4 KB artifact concurrently — they would fail with "refusing to inflate" for reasons
|
||||
// having nothing to do with them. Do not reintroduce a process-wide mutation here.
|
||||
var payload = new byte[64 * 1024]; // all zeroes: compresses to a few dozen bytes
|
||||
var bomb = Gzip(payload);
|
||||
bomb.Length.ShouldBeLessThan(1024); // the small-in / huge-out shape the guard exists for
|
||||
|
||||
DeploymentArtifact.MaxDecompressedBytes = 4096;
|
||||
var ex = Should.Throw<JsonException>(() => DeploymentArtifact.Inflate(bomb, maxBytes: 4096));
|
||||
ex.Message.ShouldContain("4096");
|
||||
ex.Message.ShouldContain("refusing to inflate");
|
||||
|
||||
var ex = Should.Throw<JsonException>(() => DeploymentArtifact.Open(bomb));
|
||||
ex.Message.ShouldContain("4096");
|
||||
// Falsifiability control: the SAME bytes at the production ceiling inflate to the full payload — so
|
||||
// the throw above is the guard firing, not the gzip stream being unreadable.
|
||||
DeploymentArtifact.Inflate(bomb, DeploymentArtifact.MaxDecompressedBytes).Length.ShouldBe(payload.Length);
|
||||
|
||||
// And the lenient statics swallow it exactly like malformed JSON.
|
||||
DeploymentArtifact.ParseComposition(bomb).EquipmentNodes.ShouldBeEmpty();
|
||||
DeploymentArtifact.ParseDriverInstances(bomb).ShouldBeEmpty();
|
||||
// …and the guard is actually wired into the door every consumer comes through: Open() at the
|
||||
// production ceiling gets past decompression and fails as JSON (64 KB of NULs is not JSON), never
|
||||
// as a bomb.
|
||||
Should.Throw<JsonException>(() => DeploymentArtifact.Open(bomb))
|
||||
.Message.ShouldNotContain("refusing to inflate");
|
||||
|
||||
// Falsifiability control: under the production limit these same bytes decompress fine (and then
|
||||
// fail as JSON, not as a bomb) — so the assertion above is the guard firing, not gzip failing.
|
||||
DeploymentArtifact.MaxDecompressedBytes = original;
|
||||
Should.Throw<JsonException>(() => DeploymentArtifact.Open(bomb))
|
||||
.Message.ShouldNotContain("refusing to inflate");
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeploymentArtifact.MaxDecompressedBytes = original;
|
||||
}
|
||||
// The over-limit JsonException lands in the same lenient catch the static wrappers already own —
|
||||
// that leniency is proved on the corrupt-gzip path in Static_wrappers_stay_lenient_on_corrupt_gzip,
|
||||
// which reaches it through the identical `catch (JsonException)` in every wrapper. Asserting it here
|
||||
// with these bytes would be vacuous: 64 KB of NULs comes back empty from a lenient wrapper whether
|
||||
// the guard fired or the JSON parse did.
|
||||
}
|
||||
|
||||
private static byte[] Gzip(byte[] plain)
|
||||
|
||||
Reference in New Issue
Block a user