Repository navigation
fix: stop signing a package from allocating several times its size - #2170
Conversation
Signing streamed the package into BouncyCastle's Ed25519Signer in 1 KiB chunks, which reads as the memory-safe way to do it and is the opposite. Pure Ed25519 hashes the message twice - once for the nonce, once for the challenge - so a signer cannot discard what it is fed, and BouncyCastle's holds it in a ByteArrayOutputStream that doubles its capacity as it fills. The ~300 MB package in #1450 therefore ended up in a 512 MB array with the 256 MB one it was copied from still live: three quarters of a gigabyte to sign 300 MB, which is why that publish needed the heap raised to 2 GB. The reported OutOfMemoryError comes straight out of that growth, at ByteArrayOutputStream.write -> ensureCapacity -> Arrays.copyOf. Read the file into one exactly sized array and sign that instead. Measured on a 9 MiB package: 33,560,752 bytes allocated streaming, 9,445,000 read in one go - 3.6x down to 1.0x. The signature is unchanged. Ed25519Signer does no more than accumulate and hand the message to Ed25519PrivateKeyParameters.sign, which is what this now calls directly, and a test asserts the two produce identical bytes so that signatures already published keep matching. Verification had the same defect the other way round, on the mirroring path, and is fixed the same way. It also now says so when handed a PEM holding something other than an Ed25519 key, rather than letting a ClassCastException out of Ed25519Signer.init. This is a smaller heap, not a bounded one: it still scales with the package, so an instance with the integrity service enabled wants a heap above ovsx.publishing.max-content-size. Constant memory is not reachable through BouncyCastle - every pure-Ed25519 sign overload takes a byte[], and the one streaming signer implements Ed25519ph, a different scheme. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
It introduces user-facing and robustness issues (misleading key-read error message, missing signature-length validation, and a potentially flaky allocation-measurement helper in tests) that should be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR changes the extension signature generation and verification flow to avoid excessive heap allocation when signing/verifying large VSIX packages by bypassing BouncyCastle’s streaming Ed25519Signer buffering behavior and using direct Ed25519 byte[] APIs instead.
Changes:
- Switch signing to
Ed25519PrivateKeyParameters.sign(...)over a singlebyte[]message buffer. - Switch verification to
Ed25519PublicKeyParameters.verify(...)over a singlebyte[]message buffer, with improved public-key type diagnostics. - Add regression tests asserting signature equivalence vs the prior streaming approach and measuring allocation behavior.
File summaries
| File | Description |
|---|---|
| server/src/main/java/org/eclipse/openvsx/publish/ExtensionVersionIntegrityService.java | Updates signing/verification to avoid multi-growth buffering and improves key-type error reporting. |
| server/src/test/java/org/eclipse/openvsx/publish/ExtensionVersionIntegrityServiceTest.java | Adds equivalence + allocation-regression tests for the updated signing approach. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Reading the whole signature and handing it to the low-level verify lost a check the streaming signer was making. Ed25519Signer.verifySignature compares the length against SIGNATURE_SIZE and answers false; the low-level Ed25519PublicKeyParameters.verify reads a fixed 64 bytes from the offset it is given, so a truncated signature came back out of BouncyCastle as "ArrayIndexOutOfBoundsException: last source index 64 out of bounds for byte[63]" and a padded one would have verified on its first 64 bytes with the rest ignored. Check the length and answer false, which is both the honest answer and what the one caller already refuses the package on. A test covers it, and fails with that same exception when the check is taken out. Also from the review: the failure of reading the public key PEM reported "Failed to read private key file", and the allocation test assumed a supported per-thread counter is a running one. It reports -1 when it is not, which would have satisfied the "allocated little" assertion for entirely the wrong reason; the counter is now switched on if it is off, and a negative reading skips rather than passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The length check sat after the package had already been pulled into memory, which in a change about not allocating needlessly is the wrong way round. Read the signature first, and check its size rather than its contents: both files arrive from a remote registry, so there is no point reading a few hundred megabytes to check something that has already failed - nor reading a "signature" that is a few hundred megabytes to find out it is not 64 bytes. The new test points verification at a package that does not exist, so reading it throws rather than merely wasting the memory. That turns "did not read it" into something a test can see: with the package read first it fails with NoSuchFileException. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Towards #1450.
The bug
createSignatureFilestreamed the package into BouncyCastle'sEd25519Signerin 1 KiB chunks. That reads as the memory-safe way to do it and is the opposite of one:Pure Ed25519 (RFC 8032) hashes the message twice — once for the nonce
H(prefix ‖ M), once for the challengeH(R ‖ A ‖ M)— so a signer cannot discard what it is fed. BouncyCastle keeps it in aByteArrayOutputStream, which doubles its capacity as it fills and allocates every intermediate array on the way.The ~300 MB package in #1450 therefore ended up in a 512 MB array with the 256 MB one it had been copied from still live — three quarters of a gigabyte to sign 300 MB. That is why 1 GB of heap failed and 2 GB worked, and the reported stack trace comes straight out of that growth:
ByteArrayOutputStream.write→ensureCapacity→Arrays.copyOf.Worth noting #1458 fixed a different OOM on this path (the
MAX_CONTENT_SIZEcheck). At 304.6 MB the reporter's file is under the 512 MB limit, so it passes that check and dies later, in signing — as they predicted in the thread.The change
Read the file into one exactly sized array and sign that. Measured on a 9 MiB package by the test below:
Ed25519SignerThe signature is unchanged.
Ed25519Signerdoes no more than accumulate the message and hand it toEd25519PrivateKeyParameters.sign, which is what this now calls directly with the same algorithm and a null context.signsExactlyAsTheStreamingSignerDidasserts the two produce identical bytes, so signatures already published keep matching what a re-sign produces.Verification had the same defect the other way round — on the mirroring path, via
MirrorExtensionService— and is fixed the same way. It now also reports a PEM holding something other than an Ed25519 key, instead of letting aClassCastExceptionout ofEd25519Signer.init.What this does not fix
A smaller heap, not a bounded one. It still scales with the package, so an instance with the integrity service enabled wants a heap above
ovsx.publishing.max-content-size(512 MB by default). Constant memory isn't reachable through BouncyCastle: everysignoverload it exposes for pure Ed25519 takes abyte[], and its only streaming signer implements Ed25519ph — a different scheme, whose signatures nothing verifying these packages today would accept. Switching to it, or shelling out to an external signer, is the discussion in #1450 and belongs in its own issue.The silent failure is untouched. #1450's second complaint is that the version is left
active = falsewhile the CLI reports success. That is still true, and there are two reasons for it worth recording:publishAsynccatchesExceptionto callmarkScanAsErrored, butOutOfMemoryErroris anError, so even an instance with scanning enabled records nothing.doPublishcarries@Retryable, and the comment above it assumes retries happen — but it is a private method invoked frompublishAsyncin the same class, so the proxy is bypassed and the annotation never applies. (@EnableResilientMethodsis onRegistryApplication, and the@Retryableon the publiccreateExtensionVersiondoes work.)Both are behavioural changes rather than a memory fix, so they are deliberately not in here.
Tests
signsExactlyAsTheStreamingSignerDid— byte-identical signatures, the guard against a format change.allocatesThePackageOnceWhereStreamingAllocatedItSeveralTimesOver— measures both ways round with per-thread allocation counters, prints the numbers, and asserts the shipped path stays under 1.5× the package while the streaming one exceeds 2×. Thresholds sit well clear of the measured 1.0× and 3.6× so it fails on a regression rather than on JVM noise; it skips where the JVM does not report per-thread allocation.testGenerateSignature, which checks a real.vsixagainst a recorded sigzip fixture, still passes.Full server suite green (1167 tests).
🤖 Generated with Claude Code