Harden parsing to answer with a reason instead of throwing, and close unsigned construction - #5
Merged
Merged
Conversation
Reading an OWID means reading whatever a caller was handed, which on a public end point is anything at all, so malformed data is an ordinary outcome rather than an exceptional one. Reporting it by throwing costs the construction and unwinding of an exception for every bad input, and whoever is sending the data chooses how often that happens. Owid.tryParse and Owid.tryParseBytes now report three facts every time, being whether it worked, the OWID only when it did, and a named OwidParseStatus either way. The new OwidReader walks the buffer by index and checks every read against what is left, so a bad envelope is a comparison that fails. It deliberately does not call the old parser and catch, because the exception would still be built and unwound and only the surface would look different. Base 64 decoding is written out here for the same reason, as the strict JDK decoder throws and the lenient one drops every character outside the alphabet, which would report text that is not base 64 at all as a missing OWID. A declared payload that cannot leave exactly the signature the version requires is BYTE_COUNT_MISMATCH whichever way the bytes fall short, including where the buffer also ended early. UNEXPECTED_END is for data that stops inside a field before the length is read. An OWID is only worth anything because it is signed, so a caller can no longer build one. The constructor is package private and the fields are final with no setters, so an instance reaches calling code only from a successful read or from Creator.createString and Creator.createBytes, which own the version, domain, date and signature. The payload and signature are handed out as copies, because a Java byte array is mutable. Creator.sign, signWithOthers, signString and signBytes are gone, as are Owid.fromBase64, Owid.fromByteArray and the Io reader they used. OwidSignatureStatus and verifyDetailed keep "could not check" apart from "does not match". A key that cannot be obtained or cannot be decoded leaves the signature unjudged, and reporting that as invalid would read as an attack rather than as the outage it is. The end point helper no longer repeats the format query parameter back in its refusal, since that value comes from whoever called the end point and a refusal is often logged. This is a deliberate breaking change to the source a consumer compiles in. The README carries a before and after table for every removed call.
Three points from reviewing the four ports together. tryParse and tryParseBytes are C# spellings. Reading is now Owid.parse, overloaded on the encoded string and on the raw bytes, which is how Java says the same thing. verifyDetailed and verifyDetailedWithPublicKey become verify, overloaded on the Crypto and on the public key PEM, so the name says what the method answers rather than how much detail it carries. Both overloads mean a caller passing a literal null has to say which one it means, which the README records. createString and createBytes keep their names, being the house convention already set by Crypto.signByteArray and verifyByteArray rather than a translation of anything. The single byte zero marker for an absent optional OWID is now refused by the whole buffer read as UNSUPPORTED_VERSION. It stands for the absence of an identifier rather than for one, carrying no domain, no date and no signature, so handing one back put an OWID in a caller's hands that nothing had ever signed, which is the state the construction boundary exists to prevent, and it could never verify. Framed reading, where the marker means an absent node inside a stream, is unaffected, and this library has no framed reader. Owid.emptyByteArray still writes the marker for embedding in someone else's framed array and now says that reading it back refuses it. A buffer with no bytes in it stays MISSING_INPUT rather than UNEXPECTED_END, because nothing supplied is not the same as data that stopped part way through a field. That was already the behaviour and now carries the reason beside it. Every member of both status vocabularies now either has a test or carries a comment saying why it cannot be reached. INVALID_INPUT_TYPE is refused by the compiler, IMPLEMENTATION_CAPACITY_EXCEEDED needs more than a Java array can hold on either surface, and MALFORMED_ENVELOPE is a backstop with no path to it while the byte count rule holds. INVALID_SIGNATURE_LENGTH is reachable only from inside the package, since reading and creation both settle the signature at 64 bytes, so it is tested there and the reason is recorded on the member.
Every port gets a public framed read, and Java had none since the only reader that did it was package private and went with the throwing parser. Owid.parse takes a ByteBuffer, which is how Java says read one item and move along. It reads one envelope from where the buffer is positioned, moves the buffer to the first byte after it, and leaves whatever follows for the next read. OwidParseResult.getByteCount reports the same distance for a caller that would rather do the arithmetic itself, and the whole buffer read reports it too, where it is the length of the buffer. The two contracts differ in one place. A whole buffer holds one envelope and nothing else, so the declared payload has to leave exactly the signature and a byte after it is a byte count mismatch. A frame only requires the declared payload and the signature to be present, because what follows is the next frame rather than rubbish. A frame that runs past the bytes supplied is reported as a truncation rather than as a byte count disagreement, since a caller reading from a source that is still arriving needs to know whether to wait for more bytes or to give up on these, and those are different answers. Nothing is consumed by a failed read, so the buffer is left at the start of the frame that failed and the caller decides what to do with it. The empty marker is refused by the framed read as well, for the reason it is refused everywhere else, being that handing one back puts an OWID in a caller's hands that nothing has ever signed. A caller walking a stream that carries markers has to skip them itself, there being no status in the shared vocabulary that means an absent node. That is worth settling across the ports. Buffers with no array a caller may reach, being direct and read only ones, are read from a copy of what remains. Wrapped arrays, which is the ordinary case, are read in place. The status coverage was rechecked against the new surface and is unchanged, with the reasons on the three untestable members widened to cover both contracts. A parse result now asserts its byte count in every test, because the check that a failed read consumes nothing turned out to measure nothing on its own, the arithmetic being zero either way.
The marker for an absent optional OWID, being the single byte zero, was reported as an unsupported version, which was not accurate. Version zero is supported and it means something, it simply is not an OWID. Reading it now reports the new OwidParseStatus.ABSENT_NODE. It still hands back no value, which is the part that matters, because the marker carries no domain, no date and no signature and returning an OWID for it would put one in a caller's hands that nothing had ever signed. Only what the caller is told has changed. Reading one frame out of something longer the marker is consumed, so a caller walking a run of frames steps over a node that is deliberately not there and reads the one after it. Reading a whole buffer the marker has to be the whole of it, and bytes after it belong to no field, which is MALFORMED_ENVELOPE. That gives MALFORMED_ENVELOPE a path to it for the first time, so it moves out of the list of members that carry a comment instead of a test, leaving two that genuinely cannot be reached. A ByteBuffer now moves on by whatever the read occupied rather than only when it succeeded, being the envelope for a success, the single byte for an absent node and nothing at all for a failure. The three cases are one rule, and a failed read still leaves the buffer at the start of the frame that failed. Also records, on the members and in the README, that reporting a short frame as UNEXPECTED_END rather than as a byte count disagreement is the settled rule across every implementation rather than a choice made here, and that BYTE_COUNT_MISMATCH means a declaration disagreeing with data that is all present, which only the whole buffer contract can meet.
The whole buffer read answered MALFORMED_ENVELOPE when the single byte marker for an absent node was followed by more bytes, while the Rust, PHP and JavaScript ports all answer ABSENT_NODE for the same input. The version byte settles the question on its own, because nothing after it can turn the value into an OWID, so this port now gives the same answer as the others on both reading contracts and still hands back no value. Checked by reading the same seven buffers through every port, being an empty buffer, the marker alone, the marker followed by bytes, a valid envelope, a valid envelope with one byte after it, one short by a byte, and an unknown version. Java was the only port that differed, and only on the third of them. All four now agree line for line. MALFORMED_ENVELOPE goes back to being unreachable, so its comment, the status table in the README and the note on the parse contract tests say so again. The README also said the domain had no encoded maximum, which stopped being true when the 253 character bound went in on read, on the creator and on the write helpers. It now records the bound and says that both reading contracts refuse an oversized declaration, naming BYTE_COUNT_MISMATCH for the whole buffer and UNEXPECTED_END for a frame. mvn test passes 99 tests, 0 failures.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hardening, and the Java half of the same change as owid-dotnet#9, owid-python#2 and owid-go#11.
Reading an OWID means reading whatever a caller was handed, which on a public end point is anything at all, so malformed data is an ordinary outcome rather than an exceptional one. Reporting it by throwing costs the construction and unwinding of an exception for every bad input, and whoever is sending the data chooses how often that happens.
What changes for a caller
Reading answers instead of throwing.
Owid.parse, overloaded on the encoded string, the raw bytes and aByteBuffer, reports three facts every time, being whether it worked, the OWID only when it did, and a named reason either way.OwidReaderwalks the buffer by index and checks every read against what is left, so a bad envelope is a comparison that fails. It deliberately does not call the old parser and catch, because the exception would still be built and unwound and only the surface would look different.A framed read, so one OWID can be read out of something longer.
Owid.parse(ByteBuffer)reads one envelope from where the buffer is positioned, moves the buffer to the first byte after it, and leaves what follows for the next read, which is how Java says read one item and move along.The two contracts differ in one place. A whole buffer holds one envelope and nothing else, so the declared payload has to leave exactly the signature and a byte after it is
BYTE_COUNT_MISMATCH. A frame only requires the declared payload and the signature to be present, because what follows is the next frame rather than rubbish.OwidParseResult.getByteCount()reports the same distance the buffer moved, for a caller that would rather do the arithmetic itself, and the whole buffer read reports it too, where it is the length of the buffer.Two points in the contract worth naming. A frame that runs past the bytes supplied is
UNEXPECTED_ENDrather thanBYTE_COUNT_MISMATCH, because a caller reading from a source that is still arriving needs to know whether to wait for more bytes or to give up on these, and those are different answers. And nothing is consumed by a failed read, so the buffer is left at the start of the frame that failed and what to do with it is the caller's to decide.Buffers with no array a caller may reach, being direct and read only ones, are read from a copy of what remains. Wrapped arrays, which is the ordinary case, are read in place.
A caller can no longer build an OWID. The constructor is package private and the fields are final with no setters, so an instance arrives from a successful read or from a creator that signs one into existence, never half made. An unsigned OWID is indistinguishable from a signed one to the code downstream of it, and the difference only surfaces later, somewhere that is not looking.
createStringandcreateBytesown the version, domain, date and signature, and both take an optional list of other OWIDs to cover with the same signature, which is whatsignWithOthersused to do.Creator.sign,signWithOthers,signStringandsignBytesare gone, along withOwid.fromBase64,Owid.fromByteArray, the setters andVersion.fromByte. The README carries a before and after row for every one of them.Naming. The new surfaces are spelled the way Java spells them rather than the way C# does. Reading is
parse, overloaded on the encoded string and on the raw bytes, not aTryParsewith a Boolean return and outputs. Verifying isverify, overloaded on theCryptoand on the public key PEM, named for what it answers rather than for how much detail it carries. Overloading means a caller passing a literalnullhas to say which one it means, as inOwid.parse((String) null), which the README records.createStringandcreateByteskeep split names rather than being overloads, following the convention this codebase already set withCrypto.signByteArrayandverifyByteArray.State is read only, and
getPayloadandgetSignaturehand back copies, so writing into what a caller was given cannot alter an OWID whose signature was computed over the original bytes.Status vocabulary
OwidParseStatusis the cross language set, so a failure means the same thing whichever language read the bytes.Every member of both vocabularies either has a test or carries a comment on the member itself saying why it cannot be reached. Four are in the second group.
OwidParseStatus.INVALID_INPUT_TYPEOwidParseStatus.IMPLEMENTATION_CAPACITY_EXCEEDEDInteger.MAX_VALUEbytes, so a larger declaration can never agree with the bytes present. The guard is kept so a future change to that arithmetic cannot silently truncate the cast below it.OwidSignatureStatus.IMPLEMENTATION_CAPACITY_EXCEEDEDVERIFICATION_ERRORby reading a message.OwidParseStatus.MALFORMED_ENVELOPEOwidSignatureStatus.INVALID_SIGNATURE_LENGTHis tested from inside the package, because a consumer cannot produce it, as both routes an OWID arrives by settle the signature at 64 bytes. The reason is recorded on the member.OwidSignatureStatusand the newverifykeep "could not check" apart from "does not match". A key that cannot be obtained or cannot be decoded leaves the signature unjudged, and calling that invalid would report an outage as an attack. That is not hypothetical, since on 30 August 2026 the key end points served PEM that a strict parser rejects and every offline verification against them failed, with the keys and identifiers both fine. The existing booleanverifyWithCryptoandverifyWithPublicKeyare unchanged.Two behaviour changes worth review
A declared payload that cannot leave exactly the required signature is now
BYTE_COUNT_MISMATCHwhichever way the bytes fall short, rather than being reported as a truncation when the buffer also ended early. What a reader can say for certain is that the declaration and the data disagree.Base 64 decoding is written out in the reader rather than handed to
java.util.Base64. Neither JDK decoder answers the question this surface asks, since the strict one throws and the MIME one silently drops every character outside the alphabet, so a string of rubbish used to come back as an empty array and be reported as a missing OWID rather than as text that is not base 64 at all. The new decoder accepts the standard alphabet with or without padding and skips spaces and line breaks, which is what the old path accepted in practice, and refuses anything else asINVALID_BASE64. That is stricter than before for text containing junk, which is the point.The empty marker says a node is absent
The single byte
0x00marker for an absent optional OWID reports the newOwidParseStatus.ABSENT_NODEand hands back no value. It used to hand back an OWID, which was the one instance reaching a caller with nothing having signed it, and calling itUNSUPPORTED_VERSIONinstead was not accurate either, since version zero is supported and does mean something.It still hands back no value, which is the part that matters, because the marker carries no domain, no date and no signature. Only what the caller is told has changed.
Read as a frame the marker is consumed, so a caller walking a run of frames steps over a node that is deliberately not there and reads the one after it.
Read as a whole buffer the marker is
ABSENT_NODEas well, whatever follows it, because the version byte settles the question on its own and nothing after it can turn the value into an OWID. An earlier head of this branch reportedMALFORMED_ENVELOPEfor a marker with bytes behind it, which no other port does, so it was changed in 5d8facf to give the same answer as owid-rust#4, owid-php#2 and owid-js#9.MALFORMED_ENVELOPEtherefore stays unreachable and stays in the untestable list above.A buffer with no bytes in it is
MISSING_INPUTrather thanUNEXPECTED_END, because nothing supplied is not the same as data that stopped part way through a field.Documentation
The README example had gone stale for the Go port without anyone noticing, so the Java example is now compiled and run as
ReadmeExampleTest. A change to the library that would break it fails the build.The payload limits section still said the domain had no encoded maximum, which stopped being true when the 253 character bound went in on the read, on the
Creatorand on the write helpers. It now records the bound, says that the library will not emit an OWID it would then refuse to read, and names both answers to a declaration that does not fit, beingBYTE_COUNT_MISMATCHon the whole buffer read andUNEXPECTED_ENDon the framed one.Verification
mvn testpasses 99 tests, 0 failures, up from 52 onmain. The complete CI matrix is green on this head, being Java 8, 11, 17 and 21 on both Ubuntu and Windows (run 33366341334). Locally only Java 21 was available, so the Java 8 runtime contract rests on that CI run rather than on anything checked by hand here. That matters more than usual for the framed read, sinceByteBuffer.position(int)gained a covariant override in Java 9 and is called throughBufferso the class file stays readable on 8.The new tests were then neutralised one at a time to prove they measure something.
declaredLengthOffByOneRefused,trailingByteAfterSignatureRefusedinvalidBase64IsReportedowidHasNoPublicConstructor,owidHasNoPublicMutation,reflectiveConstructionIsRefusedwritingIntoReturnedArraysDoesNotAlterTheOwidpayloadHandedToCreatorIsCopiedundecodableKeyIsInvalidKeyemptyMarkerIsRefusedabsentInputIsMissingInputtwoEnvelopesReadOneAfterTheOtherand the README exampleanAbsentNodeIsSteppedOverAndTheNextFrameReademptyMarkerIsAnAbsentNodeAndNotAnOwidThe construction boundary tests live in
com.example.owidconsumer, outside the library's package, because a test beside the library shares its package and could reach what a consumer cannot, so asserting the boundary from inside would measure nothing.Downstream
51Degrees/pipeline-java compiles this source into its 51Did package and calls
Owid.fromBase64,Owid.fromByteArray,new Owid(),setPayloadandcreator.sign, all of which are removed here. That consumer needs updating in its own change when it advances the OWID source pointer.Produced with AI assistance under James Rosewell's direction and needs human review.