Skip to content

Harden parsing to answer with a reason instead of raising - #2

Merged
jwrosewell merged 5 commits into
mainfrom
harden/parse-without-throwing
Aug 31, 2026
Merged

Harden parsing to answer with a reason instead of raising#2
jwrosewell merged 5 commits into
mainfrom
harden/parse-without-throwing

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

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

# before
owid = Owid.from_base64(value)          # raises on anything malformed

# after
result = Owid.parse(value)
if result:                              # truthy on success
    use(result.owid)                    # the value only when it worked
else:
    log(result.status)                  # a named reason either way

Owid.parse reads the base 64 form, Owid.parse_bytes reads a buffer holding exactly one OWID, and Owid.parse_prefix reads 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.

# before
owid = Owid(payload=b"...")
creator.sign(owid)

# after
owid = creator.create(b"...")           # creation names itself

The fields are read-only properties, because a parsed OWID's signature covers its fields as they arrived.

Two reading contracts

Owid.parse and Owid.parse_bytes read a buffer holding one OWID and nothing else, so the declared payload must leave exactly the signature and a byte after it is BYTE_COUNT_MISMATCH.

Owid.parse_prefix reads 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 is UNEXPECTED_END instead, 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_NODE by 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

ParseStatus and SignatureStatus match 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, ParseStatus and SignatureStatus are 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 and creator.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.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.

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 discover passes 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.

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.
@jwrosewell jwrosewell changed the title Harden parsing: answer with a reason instead of raising Harden parsing to answer with a reason instead of raising Aug 31, 2026
@jwrosewell
jwrosewell merged commit 0944322 into main Aug 31, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant