diff --git a/scripts/ci/pingora_edge_policy.py b/scripts/ci/pingora_edge_policy.py index 0d3a2c0948..5c1e39d9e3 100644 --- a/scripts/ci/pingora_edge_policy.py +++ b/scripts/ci/pingora_edge_policy.py @@ -58,6 +58,10 @@ MAX_FILE_BYTES = 1_048_576 MAX_RESPONSE_BYTES = 16_777_216 MAX_BLOB_BYTES = 100_000_000 +# Decoded-pixel bound for PNG validation, separate from the HTTP response cap: +# a valid 2238x2052 RGBA screenshot decodes to 18.4 MB. 128 MiB covers 16-bit +# RGBA up to 4K and 8-bit RGBA up to 6K while keeping zlib output bounded. +MAX_PNG_DECODED_BYTES = 134_217_728 REPOSITORY_RE = re.compile(r"^(?!.*(?:\.\.|\.$))[A-Za-z0-9_.-]+/(?!.*(?:\.\.|\.$))[A-Za-z0-9_.-]+$") SHA_RE = re.compile(r"^[0-9a-f]{40}$") # A base ref threaded into evaluate_pull_request may be either a branch name @@ -833,7 +837,7 @@ def _is_complete_png(raw: bytes) -> bool: pass_height = (height - y_start + y_step - 1) // y_step row_bytes = (pass_width * channels * bit_depth + 7) // 8 expected_size += pass_height * (row_bytes + 1) - if expected_size > MAX_RESPONSE_BYTES: + if expected_size > MAX_PNG_DECODED_BYTES: return False scanlines.append((pass_height, row_bytes, pass_width)) decoder = zlib.decompressobj() diff --git a/tests/test_pingora_edge_policy.py b/tests/test_pingora_edge_policy.py index 1a428556ad..aba34f6346 100644 --- a/tests/test_pingora_edge_policy.py +++ b/tests/test_pingora_edge_policy.py @@ -794,6 +794,33 @@ def chunk(kind: bytes, data: bytes) -> bytes: assert not policy._is_complete_png(signature + header + chunk(b"TEXT", b"")) +def test_png_decoded_size_is_not_bounded_by_the_http_response_cap() -> None: + """A valid 2238x2052 RGBA screenshot decodes past 16 MiB and is still a PNG. + + Regression for late-life-anxiety-reanalysis#257: five macOS window + screenshots under a declared artifact prefix were rejected, then reported + as "not valid UTF-8", because the decoded-pixel bound reused the HTTP + response cap. + """ + + def chunk(kind: bytes, data: bytes) -> bytes: + payload = kind + data + return len(data).to_bytes(4, "big") + payload + zlib.crc32(payload).to_bytes(4, "big") + + width, height = 2238, 2052 + decoded = (b"\0" + b"\0" * (width * 4)) * height + assert len(decoded) > policy.MAX_RESPONSE_BYTES + header = width.to_bytes(4, "big") + height.to_bytes(4, "big") + bytes((8, 6, 0, 0, 0)) + raw = ( + policy.PNG_SIGNATURE + + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(decoded)) + + chunk(b"IEND", b"") + ) + assert len(raw) < policy.MAX_FILE_BYTES + assert policy._is_complete_png(raw) + + def test_png_semantic_validation_fails_closed() -> None: """CRC-valid chunks still need a valid bounded PNG image stream.""" @@ -865,7 +892,7 @@ def indexed_png( assert not policy._is_complete_png(png(rgba, chunk(b"IDAT", zlib.compress(b"\0")), end)) assert not policy._is_complete_png(png(rgba, chunk(b"IDAT", zlib.compress(b"\0\0\0\0\0") + b"x"), end)) assert not policy._is_complete_png(png(rgba, chunk(b"IDAT", zlib.compress(b"\5\0\0\0\0")), end)) - huge = (policy.MAX_RESPONSE_BYTES).to_bytes(4, "big") + (1).to_bytes(4, "big") + bytes((8, 6, 0, 0, 0)) + huge = (policy.MAX_PNG_DECODED_BYTES // 4).to_bytes(4, "big") + (1).to_bytes(4, "big") + bytes((8, 6, 0, 0, 0)) assert not policy._is_complete_png(png(huge, image, end)) adam7 = (8).to_bytes(4, "big") * 2 + bytes((8, 6, 0, 0, 1))