Harden parsing to answer with a reason instead of raising - #2
Merged
Conversation
An OWID is read from whatever a caller was handed, which on a public endpoint means anything at all. Malformed data is therefore an ordinary outcome, not an exceptional one, and raising for it costs the construction and unwinding of an exception per bad input. Whoever is sending the data chooses how often that happens. try_from_base64 and try_from_byte_array answer instead, returning a result that is truthy on success and carries the OWID only then, with a named reason either way. The reader walks the buffer by index and checks each read against what is left, so a bad envelope is a comparison that fails. It does not call the raising reader and catch, because the exception would still be built and unwound. A caller can also no longer build an OWID. Python cannot make a constructor package private, so the boundary is kept by refusing a caller who has not come through one of the two allowed paths: a successful parse, or Creator.create, which owns the version, domain, date and signature. An unsigned OWID is indistinguishable from a signed one to the code downstream of it, and the difference surfaces later, somewhere that is not looking. The fields are read-only properties for the same reason. A parsed OWID's signature covers its fields as they arrived, so code that could rebind one would hold something whose signature no longer describes it. A test tampered with a signed OWID exactly that way and could not be written any more, so it now tampers with the serialised bytes and reads them back, which is how tampering actually reaches a verifier. The status vocabulary is the cross-language one, so a failure means the same thing whichever language read the bytes. A separate signature vocabulary keeps "could not check" apart from "does not match", because a key that cannot be fetched or decoded leaves the signature unjudged and calling that invalid would report an outage as an attack. Unpadded base 64 is still accepted. Encoded OWIDs occur both with and without padding, and refusing the unpadded form would reject a normal way of carrying one; a first draft of the reader did, and the round trip test caught it. pytest passes 89 tests and 36 subtests, 13 of them new, including that direct construction is refused, that no field can be rebound, that a megabyte payload parses, and that a structurally valid identifier with a signature that does not match parses and then fails verification.
Three changes after reviewing the ports side by side. The names read as .NET wearing snake_case. Python has no "try" convention, so a parse that returns a result is just a parse: try_from_base64 becomes parse and try_from_byte_array becomes parse_bytes. The signature vocabulary was declared and nothing produced it, which is a claim with no code behind it. signature_status now returns it, keeping "could not check" apart from "does not match". A key that cannot be decoded is InvalidKey and an absent key is KeyUnavailable, neither of which may ever be reported as a forgery, because a caller acting on "invalid" would reject good identifiers during an outage. That is what happened on 30 August 2026, when the key endpoints served PEM a strict parser rejects. Two cross-language disagreements are settled, both found by comparing the ports rather than by any one of them. The version 0 empty marker is refused: it stands for an absent node inside a stream, carries no signature and can never verify, so accepting it was the one case of an unsigned instance reaching a caller. An empty buffer is MissingInput rather than UnexpectedEnd, because nothing was supplied, which is not the same as data that stopped part way through a field. Every failure condition now has a test, and the three that cannot be reached in Python say so and why, so the gaps are stated decisions rather than something nobody noticed. pytest passes 104 tests and 36 subtests.
Every port now has one, and Python had none. It reads one envelope from the start of a buffer that may hold more, and differs from the whole buffer read in exactly one place: a whole buffer knows where the envelope ends, so the declared payload must leave exactly the signature, while here what follows may be the next envelope rather than rubbish, so the declaration and the signature need only be present. The result carries how many bytes the envelope occupied, so a caller walks a run by slicing. Nothing is consumed when one is refused, because a half read envelope leaves a caller somewhere it cannot reason about, and a second read of the same bytes gives the same answer. Both reads share one walk, so everything except what follows the envelope is judged identically and a caller does not have to learn two vocabularies. Four tests, and neutralising the one comparison that separates the two contracts fails the run of envelopes, so they measure the thing they name. pytest passes 108 tests and 40 subtests.
Two decisions from comparing the seven ports once they all had a framed read. Both came from a port disagreeing rather than from any one of them being obviously right. A frame whose declared payload runs past the bytes supplied now reports UnexpectedEnd rather than ByteCountMismatch. owid-java argued it and the argument holds: that is data stopping early, not a declaration disagreeing with data that is all present, and a caller reading from a source still arriving needs to know whether waiting for more bytes would help. ByteCountMismatch keeps its documented meaning and is reachable only on the whole buffer contract, where every byte is present by definition. The version 0 marker is now AbsentNode rather than UnsupportedVersion. It stands for an absent node inside a stream, which is exactly what framed reading is for, and refusing it outright left a caller walking a run of frames unable to tell an absent node from a malformed one. Calling it an unsupported version was also inaccurate, since version 0 is supported and meaningful, it simply is not an OWID. It still hands back no value, because it carries no signature and can never verify, and the framed read moves past its one byte so the node can be skipped deliberately. pytest passes 109 tests and 40 subtests, including reading the envelope that follows a marker.
The README still described the API the hardening replaced, so a reader was told to call Owid.from_base64, to build an Owid directly, and to sign one with creator.sign_with_others, none of which exist any more. Every example and every interface entry now names what the package really offers, being Owid.parse, Owid.parse_bytes and Owid.parse_prefix answering with a ParseResult, and Creator.create and Creator.create_string producing a signed OWID in one step. The reading contract is written down for the first time. Malformed input is a normal result rather than an exception, every status is listed with what it means, the whole buffer read requires the declared payload to leave exactly the signature so a trailing byte is a byte count mismatch, and the framed read requires only that the payload and the signature are present because what follows may be the next envelope, so stopping short is an unexpected end instead. The marker for a node that is absent is reported as itself with its one byte consumed on a framed read, so a caller walking a run of frames reaches the next envelope. ParseResult, ParseStatus and SignatureStatus are exported from the package root, as the Rust port exports its own, because a caller cannot act on a result without naming the reason the result carries. The docstrings carried the same fault. The error module said a malformed buffer raises, and the low level Reader read as though it were the way to read external data when both are now reached only through a private route the tests use. The private base 64 raising route and the decode helper it called had no callers at all, so both are removed. The new tests/test_readme.py runs the Python examples in the README in the order they appear, so documentation naming a method that does not exist fails the build. The suite is 111 tests and passes on Python 3.9 and 3.14.
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 Python half of the same change as owid-dotnet#9.
Reading an OWID means reading whatever a caller was handed, which on a public endpoint is anything at all, so malformed data is an ordinary outcome rather than an exceptional one. Raising for it costs the construction and unwinding of an exception per bad input, and whoever sends the data chooses how often that happens.
What changes
Owid.parsereads the base 64 form,Owid.parse_bytesreads a buffer holding exactly one OWID, andOwid.parse_prefixreads one from the front of a buffer that may carry more after it.A caller can no longer build an OWID. Python cannot make a constructor package private, so the boundary is kept by refusing a caller who has not come through one of the two allowed paths, being a successful parse, or
Creator.create, which owns the version, domain, date and signature.The fields are read-only properties, because a parsed OWID's signature covers its fields as they arrived.
Two reading contracts
Owid.parseandOwid.parse_bytesread a buffer holding one OWID and nothing else, so the declared payload must leave exactly the signature and a byte after it isBYTE_COUNT_MISMATCH.Owid.parse_prefixreads a frame, so it needs only the declared payload and the signature to be present and says nothing about what follows, which may be the next envelope. A frame whose declaration runs past the bytes supplied isUNEXPECTED_ENDinstead, because there the bytes may still be arriving and a caller has to be able to tell waiting for more from giving up.The one byte marker for a node that is absent is reported as
ABSENT_NODEby both reads, and neither hands back an OWID because the marker carries no signature and so can never verify. A framed read counts its one byte as consumed, so a caller walking a run of frames steps over the absent node and reaches the next envelope.Cross-language vocabulary
ParseStatusandSignatureStatusmatch the other ports, so a failure means the same thing whichever language read the bytes. The signature vocabulary keeps "could not check" apart from "does not match", as a key that cannot be fetched or decoded leaves the signature unjudged and calling that invalid would report an outage as an attack.ParseResult,ParseStatusandSignatureStatusare exported from the package root, the way owid-rust exports its own, because a caller cannot act on a result without naming the reason the result carries.Documentation
The README described the API this change replaced, naming
Owid.from_base64,Owid.from_byte_array, direct construction andcreator.sign_with_others, none of which exist. Every example and every interface entry now names the real surface, the reading contracts and the status vocabulary are written down, and the docstrings that said a malformed buffer raises are corrected. The private base 64 raising route and the decode helper it called had no callers at all, so both are removed.tests/test_readme.pyruns the Python examples in the README in the order they appear, so documentation naming a method that does not exist fails the build.Two things caught while writing it
A first draft of the reader used
b64decode(validate=True)and refused unpadded base 64, which is a normal way to carry an OWID. The round trip test caught it and padding is added again before decoding.A test tampered with a signed OWID by rebinding
owid.payload, which is exactly what the change prevents, so it now tampers with the serialised bytes and reads them back, which is how tampering actually reaches a verifier.Verification
python -m unittest discoverpasses 111 tests, 0 failures, on Python 3.9 and on Python 3.14. The four Python examples in the README were also extracted and run on their own against the installed package, and they produce the results the surrounding text claims.Produced with AI assistance under James Rosewell's direction and needs human review.