fix(runtime): parallel-safe decompression limit; tighten ParsedDeploymentArtifact surface

This commit is contained in:
Joseph Doherty
2026-08-15 17:16:09 -04:00
parent 5f2e60278c
commit 14c31941c7
3 changed files with 62 additions and 35 deletions
@@ -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)