diff --git a/README.md b/README.md
index 8efaba1..0a88d7e 100644
--- a/README.md
+++ b/README.md
@@ -36,15 +36,24 @@ creates, signs, serializes, and verifies OWIDs.
The OWID wire format stores the payload length as an unsigned 32 bit value,
so a payload from zero through 4,294,967,295 bytes is structurally valid. The
-format defines no smaller payload limit. The null-terminated domain has no
-separate encoded maximum, so the protocol alone is not an application input
-limit for the complete envelope.
+format defines no smaller payload limit. The null-terminated domain is capped
+at 253 characters, so that field is at most 254 bytes with its terminator,
+which leaves the payload as the only part of the envelope the protocol leaves
+open ended, so the protocol alone is not an application input limit for the
+complete envelope.
This library validates that the declared payload length agrees with the bytes
present before it sizes or copies the payload. A large declaration without
-the corresponding bytes is malformed and is rejected without allocating the
-declared size. A matching large payload is not malformed merely because it is
-large, and parsing work and memory use scale with the bytes actually present.
+the corresponding bytes is malformed, and is reported as
+`BYTE_COUNT_MISMATCH` on the whole buffer read, or as `UNEXPECTED_END` on the
+framed read, without allocating the declared size either way. A matching large
+payload is not malformed merely because it is large, and parsing work and
+memory use scale with the bytes actually present.
+
+The 253 character maximum binds this library on both sides. A buffer whose
+domain field runs past it is refused when it is read, and a domain longer than
+it is refused when a `Creator` is built and again when the domain is written,
+so the library will not emit an OWID that it would then refuse to read.
The in-memory APIs remain subject to Java's signed `int` array indexing,
address-space and available-memory limits, so a single Java byte array cannot
@@ -62,7 +71,8 @@ on behalf of the application.
## Installation
-Build and install with Maven. The project targets Java 21.
+Build and install with Maven. The library is compiled for Java 8, and the
+test suite runs on Java 8, 11, 17 and 21.
```
mvn install
@@ -80,10 +90,15 @@ Then depend on it from another Maven project.
## Usage
+The example below is compiled and run by the test suite as
+`ReadmeExampleTest`, so a change to the library that would break it fails the
+build rather than leaving a documented example that no longer works.
+
```java
import com.swancommunity.owid.Creator;
import com.swancommunity.owid.Crypto;
import com.swancommunity.owid.Owid;
+import com.swancommunity.owid.OwidParseResult;
import java.util.Collections;
@@ -91,44 +106,199 @@ import java.util.Collections;
Crypto crypto = Crypto.generate();
Creator creator = Creator.create("example.com", crypto);
-// Create and sign an OWID with a payload.
-Owid owid = creator.signString("Hello World");
+// Create a signed OWID with a payload. An OWID is signed from the moment it
+// exists, so there is never an unsigned one to hold.
+Owid owid = creator.createString("Hello World");
// Serialize to base 64 for storage or transmission.
String encoded = owid.asBase64();
-// Later, or elsewhere, decode and verify with the public key.
-Owid copy = Owid.fromBase64(encoded);
-String publicPem = crypto.publicKeyPem();
-boolean valid = copy.verifyWithPublicKey(publicPem, Collections.emptyList());
+// Later, or elsewhere, read it back. Reading answers rather than throwing,
+// because whatever arrives from outside may not be an OWID at all.
+OwidParseResult result = Owid.parse(encoded);
+if (result.isSuccess()) {
+ Owid copy = result.getValue();
+ String publicPem = crypto.publicKeyPem();
+ boolean valid = copy.verifyWithPublicKey(
+ publicPem, Collections. Kept apart from the other failures so that a verification result can
+ * report {@link OwidSignatureStatus#IMPLEMENTATION_CAPACITY_EXCEEDED}
+ * without reading the text of a message. Running out of room is not the same
+ * as the data being wrong, because the same OWID may be readable on a
+ * runtime with more of it. Not public, because callers catch {@link OwidException} and the
+ * distinction is only needed inside the library. A creator binds the domain that hosts the well known end points to the
- * crypto instance holding the signing key. Signing an OWID sets its domain to
- * the creator domain, its date to the current time, and its version to the
- * current version, then produces the signature.
There is no way to sign an OWID that already exists, because there is no + * way to obtain an unsigned one, so nothing outside the library is available + * to be signed. Signing a parsed OWID again would replace the signature its + * fields were read with, which is why the library does not offer it.
*/ public final class Creator { @@ -109,63 +114,82 @@ public Crypto crypto() { } /** - * Signs the OWID provided, setting the domain to the creator domain, the - * date to the current time, and the version to the current version. + * Creates a new signed OWID for this creator carrying the string as the + * UTF-8 payload. * - * @param owid the OWID to sign - * @throws OwidException if a field cannot be encoded or the signing - * operation fails + * @param value the payload string + * @return the signed OWID + * @throws OwidException if the payload is null, a field cannot be + * encoded, or the signing operation fails */ - public void sign(Owid owid) throws OwidException { - signWithOthers(owid, Collections.emptyList()); + public Owid createString(String value) throws OwidException { + return createString(value, Collections.This is one of only two ways an OWID reaches calling code, the other + * being a successful read of a complete serialized one. The creator owns + * the version, the domain, the date and the signature, and a caller + * supplies the payload and nothing else, so there is no moment at which a + * partly built OWID exists for anyone to hold or pass on.
+ * + * @param value the payload bytes + * @param others the other OWIDs to cover with the signature * @return the signed OWID - * @throws OwidException see {@link #sign(Owid)} + * @throws OwidException see {@link #createBytes(byte[])} */ - public Owid signBytes(byte[] value) throws OwidException { - Owid owid = new Owid(); - owid.setPayload(value); - sign(owid); - return owid; + public Owid createBytes(byte[] value, ListReading lives in {@link OwidReader}, which walks a buffer by index and + * reports why rather than throwing, because the bytes it reads come from + * outside.
* *The class is not part of the public API. The methods are package private * so that the unit tests can exercise them directly.
@@ -63,138 +67,6 @@ static Instant baseDate() { return Instant.ofEpochSecond(BASE_DATE_EPOCH_SECONDS); } - /** Sequential reader over a byte buffer. */ - static final class Reader { - - private final byte[] buffer; - private int position; - - Reader(byte[] buffer) { - this.buffer = buffer; - this.position = 0; - } - - int position() { - return position; - } - - int readByte() throws OwidException { - if (position >= buffer.length) { - throw endOfBuffer(); - } - return buffer[position++] & 0xFF; - } - - /** - * Copies the next count bytes. The end of the buffer is checked - * before the copy is sized, so a count beyond the bytes present is - * refused without allocating. - */ - private byte[] readBytes(int count) throws OwidException { - if (count < 0) { - throw new OwidException("payload length is negative"); - } - long end = (long) position + count; - if (end > buffer.length) { - throw endOfBuffer(); - } - byte[] value = new byte[count]; - System.arraycopy(buffer, position, value, 0, count); - position += count; - return value; - } - - /** - * Reads the domain, being the bytes up to the null terminator. The - * search stops at {@link Io#MAXIMUM_DOMAIN_LENGTH} rather than at - * the end of the buffer, so a buffer whose terminator is missing or - * corrupted costs no more than the published maximum however long - * that buffer is, and a domain longer than the maximum is refused - * without reading past it. - */ - String readString() throws OwidException { - long lastTerminator = (long) position + MAXIMUM_DOMAIN_LENGTH; - int limit = (int) Math.min(buffer.length - 1L, lastTerminator); - int terminator = -1; - for (int i = position; i <= limit; i++) { - if (buffer[i] == 0) { - terminator = i; - break; - } - } - if (terminator < 0) { - if (lastTerminator < buffer.length) { - throw domainTooLong(); - } - throw endOfBuffer(); - } - String value = new String(buffer, position, terminator - position, - StandardCharsets.UTF_8); - position = terminator + 1; - return value; - } - - /** Reads an unsigned 32 bit integer in little endian byte order. */ - long readUInt32() throws OwidException { - return ((long) readByte()) - | ((long) readByte() << 8) - | ((long) readByte() << 16) - | ((long) readByte() << 24); - } - - /** - * Reads the length prefixed payload. The count is whatever the sender - * declared, so it is checked against the bytes actually present - * before anything is sized by it. A valid OWID is the declared - * payload followed by the signature and nothing else, so the count - * must equal the bytes remaining less the signature length, and any - * other count, short or long, is refused here. The same check refuses - * an envelope with bytes after the signature, which was previously - * accepted and ignored, and one whose signature is short. - */ - byte[] readByteArray() throws OwidException { - long count = readUInt32(); - long remaining = (long) buffer.length - position; - long expected = count + Owid.SIGNATURE_LENGTH; - if (remaining != expected) { - throw new OwidException("OWID payload length '" + count - + "' does not match the '" + remaining - + "' bytes present, of which the final '" - + Owid.SIGNATURE_LENGTH + "' must be the signature"); - } - return readBytes((int) count); - } - - /** Reads the fixed length signature. */ - byte[] readSignature() throws OwidException { - return readBytes(Owid.SIGNATURE_LENGTH); - } - - /** Reads the date using the encoding associated with the version. */ - Instant readDate(Version version) throws OwidException { - switch (version) { - case VERSION1: { - int high = readByte(); - int low = readByte(); - long hours = ((long) high << 8) | low; - return baseDate().plus(Duration.ofHours(hours)); - } - case VERSION2: - case VERSION3: { - long minutes = readUInt32(); - return baseDate().plus(Duration.ofMinutes(minutes)); - } - default: - throw new OwidException("OWID version '" - + (version.asByte() & 0xFF) + "' not supported"); - } - } - - private static OwidException endOfBuffer() { - return new OwidException("buffer ended before the OWID was complete"); - } - } - static void writeByte(ByteArrayOutputStream buffer, byte value) { buffer.write(value); } diff --git a/src/main/java/com/swancommunity/owid/Owid.java b/src/main/java/com/swancommunity/owid/Owid.java index b9cd6ba..51f06cd 100644 --- a/src/main/java/com/swancommunity/owid/Owid.java +++ b/src/main/java/com/swancommunity/owid/Owid.java @@ -17,10 +17,11 @@ package com.swancommunity.owid; import java.io.ByteArrayOutputStream; +import java.nio.Buffer; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Base64; import java.util.List; @@ -30,8 +31,23 @@ * *An OWID records that the processor operating the domain handled the * payload, and any other OWIDs covered by the signature, at the date and time - * given. Once signed it is immutable. Any change to the fields will cause - * verification to fail.
+ * given. + * + *An OWID is only worth anything because it is signed, so a caller cannot + * build one. An instance reaches calling code by one of two routes, being + * {@link #parse(String)}, {@link #parse(byte[])} or + * {@link #parse(ByteBuffer)} reading bytes that were already a complete + * OWID, or {@link Creator#createBytes(byte[])} + * and its companions signing one into existence. There is deliberately no way + * to assemble a half made one, because an unsigned OWID is indistinguishable + * from a signed one to the code downstream of it and the difference only + * surfaces later, when a verification fails somewhere nobody is watching.
+ * + *The state is read only for the same reason. The signature covers the + * fields as they arrived, so a caller changing one afterwards would hold + * something the signature no longer describes. The payload and signature are + * handed out as copies, because a Java byte array is mutable and a caller + * writing into one it was given would otherwise reach inside the OWID.
* *The serialized form places the fields in this order. Multi byte integers * are little endian, except the version 1 date which is big endian.
@@ -55,86 +71,150 @@ public final class Owid { */ public static final int SIGNATURE_LENGTH = 64; - private Version version; - private String domain; - private Instant date; - private byte[] payload; - private byte[] signature; + private final Version version; + private final String domain; + private final Instant date; + private final byte[] payload; + private final byte[] signature; /** - * Creates an empty unsigned OWID with the current version, an empty - * domain, the current date truncated to the minute, an empty payload, and - * no signature. + * Builds an instance from fields a reader or the creator has already + * settled. + * + *Package private, so only this library can call it. That is the whole + * construction boundary, because a consumer compiled against the library + * cannot name this constructor at all, so there is no way to obtain an + * OWID that has not either been read from a complete serialized one or + * been signed by a {@link Creator}. The arrays are taken as given because + * every caller inside the library hands over an array nothing else + * holds.
*/ - public Owid() { - this.version = Version.current(); - this.domain = ""; - this.date = Instant.now().truncatedTo(ChronoUnit.MINUTES); - this.payload = new byte[0]; - this.signature = new byte[0]; + Owid(Version version, String domain, Instant date, byte[] payload, + byte[] signature) { + this.version = version; + this.domain = domain; + this.date = date; + this.payload = payload; + this.signature = signature; } /** - * Creates a new unsigned OWID with the domain, date, and payload provided - * and the current version. + * Reads a complete OWID from its base 64 form. * - * @param domain the domain associated with the creator - * @param date the creation date, used to the minute - * @param payload the payload bytes + *The value may be anything at all, because this is external data and + * failing to be an OWID is an ordinary outcome rather than an error. The + * result reports whether it worked, the OWID only when it did, and a + * named reason either way. Decoding accepts the standard alphabet with or + * without the trailing padding, and ignores line breaks and spaces.
+ * + *A successful read says the bytes are a structurally valid OWID. It + * says nothing about whether the signature is genuine, which is a + * separate question answered by {@link #verify(Crypto, List)}.
+ * + * @param value the base 64 encoded OWID, which may be null + * @return the OWID and {@link OwidParseStatus#PARSED}, or no value and + * the reason the string is not an OWID */ - public Owid(String domain, Instant date, byte[] payload) { - this.version = Version.current(); - this.domain = domain; - this.date = date; - this.payload = payload.clone(); - this.signature = new byte[0]; + public static OwidParseResult parse(String value) { + if (value == null || value.isEmpty()) { + return OwidParseResult.failed(OwidParseStatus.MISSING_INPUT); + } + byte[] buffer = OwidReader.decodeBase64(value); + if (buffer == null) { + return OwidParseResult.failed(OwidParseStatus.INVALID_BASE64); + } + return OwidReader.read(buffer, 0, buffer.length, false); } /** - * Creates an OWID from a base 64 encoded string. Decoding accepts the - * standard alphabet with or without the trailing padding. + * Reads a complete OWID from a buffer holding exactly one. + * + *The buffer must be one whole OWID and nothing else, so bytes after + * the envelope are refused as {@link OwidParseStatus#BYTE_COUNT_MISMATCH} + * because on this surface there is nothing else they could belong to. To + * read one envelope out of something longer, and leave what follows for + * the next read, use {@link #parse(ByteBuffer)}.
* - * @param value the base 64 encoded OWID - * @return the parsed OWID - * @throws OwidException if the string is not valid base 64, or the bytes - * are not a valid OWID + * @param buffer the serialized OWID bytes, which may be null + * @return the OWID and {@link OwidParseStatus#PARSED}, or no value and + * the reason the bytes are not an OWID */ - public static Owid fromBase64(String value) throws OwidException { - return fromByteArray(decodeBase64(value)); + public static OwidParseResult parse(byte[] buffer) { + if (buffer == null) { + return OwidParseResult.failed(OwidParseStatus.MISSING_INPUT); + } + return OwidReader.read(buffer, 0, buffer.length, false); } /** - * Creates an OWID from its binary form. - * - * @param buffer the serialized OWID bytes - * @return the parsed OWID - * @throws OwidException if the first byte is not a known version, the - * buffer is too short for the remaining fields, the - * domain is unterminated or longer than the maximum - * published for a domain name, or the declared - * payload length does not leave exactly the 64 byte - * signature at the end + * Reads one OWID from where the buffer is positioned, leaving whatever + * follows for the next read. + * + *This is the framed read, for input that carries an OWID inside + * something longer, such as a tree of them or a record with other fields + * around it. It differs from {@link #parse(byte[])} in one place only. + * A whole buffer has to end where the envelope does, so a byte after the + * signature belongs to no field, whereas here the declared payload and + * the signature only have to be present and what follows is the next + * frame rather than rubbish.
+ * + *On success the buffer is moved on to the first byte after the + * envelope, so calling this again reads the next one, and + * {@link OwidParseResult#getByteCount()} reports how far it moved. On + * failure the buffer is left exactly where it was and nothing is + * consumed, because a half read frame leaves a caller somewhere it cannot + * reason about, so what to do with a bad frame is the caller's to + * decide.
+ * + *
+ * ByteBuffer buffer = ByteBuffer.wrap(bytes);
+ * while (buffer.hasRemaining()) {
+ * OwidParseResult result = Owid.parse(buffer);
+ * if (result.isSuccess() == false) {
+ * break;
+ * }
+ * use(result.getValue());
+ * }
+ *
+ *
+ * {@link OwidParseStatus#UNEXPECTED_END} here means the frame runs + * past the bytes supplied, so a caller reading from a growing source can + * wait for more and read again from the same position.
+ * + * @param buffer the bytes to read from, which may be null + * @return the OWID and {@link OwidParseStatus#PARSED}, or no value and + * the reason the bytes are not an OWID */ - public static Owid fromByteArray(byte[] buffer) throws OwidException { - return fromReader(new Io.Reader(buffer)); - } - - /** Creates an OWID by reading the next fields from the reader. */ - static Owid fromReader(Io.Reader reader) throws OwidException { - Version version = Version.fromByte(reader.readByte()); - Owid owid = new Owid(); - owid.version = version; - if (version == Version.EMPTY) { - owid.domain = ""; - owid.payload = new byte[0]; - owid.signature = new byte[0]; - return owid; + public static OwidParseResult parse(ByteBuffer buffer) { + if (buffer == null || buffer.hasRemaining() == false) { + return OwidParseResult.failed(OwidParseStatus.MISSING_INPUT); + } + OwidParseResult result; + if (buffer.hasArray()) { + int base = buffer.arrayOffset(); + result = OwidReader.read(buffer.array(), + base + buffer.position(), base + buffer.limit(), true); + } else { + // A direct or read only buffer has no array to walk, so the bytes + // are taken a copy of. Ordinary callers wrap an array and never + // reach this, and the copy is of what remains rather than of the + // envelope, because how long the envelope is cannot be known + // until it has been read. + byte[] remaining = new byte[buffer.remaining()]; + ByteBuffer view = buffer.duplicate(); + view.get(remaining); + result = OwidReader.read(remaining, 0, remaining.length, true); } - owid.domain = reader.readString(); - owid.date = reader.readDate(version); - owid.payload = reader.readByteArray(); - owid.signature = reader.readSignature(); - return owid; + // The buffer moves on by exactly what the read occupied, which is the + // envelope for a success, the single byte for an absent node, and + // nothing at all for a failure. A failed read therefore leaves the + // buffer at the start of the frame that failed. + // + // Buffer.position is called rather than ByteBuffer.position because + // the covariant override arrived in Java 9 and this library is built + // for 8. + ((Buffer) buffer).position(buffer.position() + result.getByteCount()); + return result; } /** @@ -163,13 +243,23 @@ public String asBase64() throws OwidException { /** Appends the OWID, including the signature, to the buffer provided. */ void toBuffer(ByteArrayOutputStream buffer) throws OwidException { - toBufferNoSignature(buffer); + writeNoSignature(buffer, version, domain, date, payload); Io.writeSignature(buffer, signature); } /** - * Writes an empty OWID marker. Used to indicate optional OWIDs in byte - * arrays. + * Writes the marker for an absent optional OWID, being the single byte + * zero, for embedding in a larger framed byte array. + * + *Reading it back reports {@link OwidParseStatus#ABSENT_NODE} and + * hands back no value, because the marker stands for the absence of an + * identifier rather than for one, and an OWID with no domain, no date and + * no signature would be one nothing had ever signed. The first byte + * settles that on both reading contracts, since nothing after it can + * turn the value into an OWID. Read as a frame, through + * {@link #parse(ByteBuffer)}, the marker is consumed, so a caller walking + * a run of frames steps over the absent node and reads the frame after + * it.
* * @return a single byte array holding the empty marker */ @@ -181,7 +271,9 @@ public static byte[] emptyByteArray() { * Appends the fields other than the signature to the buffer. This is the * data over which the signature is calculated. */ - void toBufferNoSignature(ByteArrayOutputStream buffer) throws OwidException { + private static void writeNoSignature(ByteArrayOutputStream buffer, + Version version, String domain, Instant date, byte[] payload) + throws OwidException { Io.writeByte(buffer, version.asByte()); Io.writeString(buffer, domain); Io.writeDate(buffer, date, version); @@ -194,23 +286,43 @@ void toBufferNoSignature(ByteArrayOutputStream buffer) throws OwidException { * form of each of the others in the order provided. */ byte[] dataForCrypto(ListA key that cannot be used leaves the signature unjudged, and saying + * that it is invalid would report an outage as an attack, so the two + * arrive as different statuses.
+ * + * @param crypto the crypto instance holding the public key, which may be + * null when no key could be obtained + * @param others the other OWIDs that were signed together with this one, + * in the same order as when signed + * @return the outcome of the check */ - public Version getVersion() { - return version; + public OwidVerificationResult verify(Crypto crypto, ListKey material that cannot be decoded reports + * {@link OwidSignatureStatus#INVALID_KEY}, because the fault is in the + * key rather than in the identifier.
+ * + * @param publicPem the public key in SPKI PEM form, which may be null + * when no key could be obtained + * @param others the other OWIDs that were signed together with this + * one, in the same order as when signed + * @return the outcome of the check */ - public void setVersion(Version version) { - this.version = version; + public OwidVerificationResult verify(String publicPem, + ListReading a serialized OWID does not raise this. Data that arrived from + * outside is expected to be malformed sometimes, so + * {@link Owid#parse(String)} and {@link Owid#parse(byte[])} report + * an {@link OwidParseStatus} instead. What remains here is the caller's own + * mistakes, such as an invalid creator domain or a field that cannot be + * serialized, and failures of the cryptography.
*/ public class OwidException extends Exception { diff --git a/src/main/java/com/swancommunity/owid/OwidParseResult.java b/src/main/java/com/swancommunity/owid/OwidParseResult.java new file mode 100644 index 0000000..8c1863c --- /dev/null +++ b/src/main/java/com/swancommunity/owid/OwidParseResult.java @@ -0,0 +1,139 @@ +/* **************************************************************************** + * Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * ***************************************************************************/ + +package com.swancommunity.owid; + +/** + * What a read of a serialized OWID produced, and why. + * + *Every read reports the same three facts, so a caller never has to infer + * one of them from another. Whether it worked is + * {@link #isSuccess()}, the OWID is {@link #getValue()} and is present only + * on success, and the reason is {@link #getStatus()} either way.
+ * + *The three move together. When {@link #isSuccess()} is true the value is + * not null and the status is {@link OwidParseStatus#PARSED}, and when it is + * false the value is null and the status names one of the expected + * problems, or says the bytes were the marker for an absent node.
+ * + *A result carries no text taken from the input. The bytes came from + * outside, so putting them in a message would mean logging whatever an + * untrusted sender chose to send.
+ */ +public final class OwidParseResult { + + private final Owid value; + + private final OwidParseStatus status; + + private final int byteCount; + + private OwidParseResult(Owid value, OwidParseStatus status, + int byteCount) { + this.value = value; + this.status = status; + this.byteCount = byteCount; + } + + /** + * The result of a read that produced the OWID given, occupying the number + * of bytes given. + */ + static OwidParseResult parsed(Owid value, int byteCount) { + return new OwidParseResult(value, OwidParseStatus.PARSED, byteCount); + } + + /** The result of a read that failed for the reason given. */ + static OwidParseResult failed(OwidParseStatus status) { + return new OwidParseResult(null, status, 0); + } + + /** + * The result of reading the marker for an absent node, which hands back + * no OWID but does occupy the bytes given, so a caller reading one frame + * after another steps over the absent node and reads the one after it. + */ + static OwidParseResult absentNode(int byteCount) { + return new OwidParseResult( + null, OwidParseStatus.ABSENT_NODE, byteCount); + } + + /** + * Whether the bytes were a complete, structurally valid OWID. This says + * nothing about whether the signature is genuine, which is a separate + * question answered by + * {@link Owid#verify(Crypto, java.util.List)}. + * + * @return true when the read produced an OWID + */ + public boolean isSuccess() { + return status == OwidParseStatus.PARSED; + } + + /** + * The OWID that was read, or null when there was none. Callers should + * test {@link #isSuccess()} first rather than testing this for null, + * because the status also says which of the expected problems it was, or + * that the bytes were the marker for an absent node. + * + * @return the OWID on success, otherwise null + */ + public Owid getValue() { + return value; + } + + /** + * Why the read succeeded or failed. + * + * @return {@link OwidParseStatus#PARSED} on success, otherwise the + * specific reason + */ + public OwidParseStatus getStatus() { + return status; + } + + /** + * How many bytes the read occupied. + * + *This is what a caller reading one frame after another needs in order + * to find the next one. {@link Owid#parse(java.nio.ByteBuffer)} moves the + * buffer along by this much itself, so a caller using that surface does + * not have to. On a successful whole buffer read this is the length of + * the buffer, because there the envelope is the whole of it.
+ * + *Three cases. The length of the envelope on success, one byte for + * {@link OwidParseStatus#ABSENT_NODE} so that a caller steps over the + * marker and reads the frame after it, and zero for every failure, since + * a read that failed consumed nothing and left the caller where it + * started.
+ * + * @return the bytes the read occupied, or zero + */ + public int getByteCount() { + return byteCount; + } + + /** + * The status name on its own. The input is deliberately absent, because + * a parse failure is often logged and the bytes came from outside. + * + * @return the status name + */ + @Override + public String toString() { + return status.name(); + } +} diff --git a/src/main/java/com/swancommunity/owid/OwidParseStatus.java b/src/main/java/com/swancommunity/owid/OwidParseStatus.java new file mode 100644 index 0000000..06f7ac7 --- /dev/null +++ b/src/main/java/com/swancommunity/owid/OwidParseStatus.java @@ -0,0 +1,145 @@ +/* **************************************************************************** + * Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * ***************************************************************************/ + +package com.swancommunity.owid; + +/** + * Why a read of external data succeeded or failed. + * + *Malformed data arriving from outside is expected rather than + * exceptional. An OWID is read from whatever a caller was handed, which on a + * public end point means anything at all, so every one of these outcomes is + * an ordinary result and not a fault. Reporting them 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.
+ * + *These names are the cross language vocabulary. Each implementation + * spells the surface in its own idiom, so the Java members are the Java + * naming convention for the same set of facts, and a failure means the same + * thing whichever language read the bytes.
+ */ +public enum OwidParseStatus { + + /** + * The bytes form a structurally valid OWID. This says nothing about the + * signature, which is a separate question with its own answer. + */ + PARSED, + + /** Nothing was supplied to read, being a null or empty value. */ + MISSING_INPUT, + + /** + * The input was supplied in a form this surface cannot read. Kept for + * the cross language vocabulary and not reachable in Java, where the + * compiler already refuses anything that is not a string or a byte + * array. + */ + INVALID_INPUT_TYPE, + + /** The string is not valid base 64, so there are no bytes to read. */ + INVALID_BASE64, + + /** The first byte names a version this implementation does not know. */ + UNSUPPORTED_VERSION, + + /** + * The data stopped in the middle of a field. Different from + * {@link #BYTE_COUNT_MISMATCH}, which is a declaration disagreeing with + * data that is all present. + * + *Reading one frame out of something longer, this also covers a frame + * whose declared payload and signature run past the bytes supplied, so a + * caller reading from a source that is still arriving can wait for more + * and read again from the same place. That is the settled rule across + * every implementation, not a choice this one made, because knowing + * whether to wait for more bytes or to give up on these is the thing a + * caller of a framed read most needs to be told.
+ */ + UNEXPECTED_END, + + /** + * The creator domain is not terminated, or is longer than the maximum + * published for a domain name. + */ + INVALID_DOMAIN_ENCODING, + + /** + * The declared payload byte count disagrees with the bytes actually + * present. Checked before anything is sized by the declaration, so a + * sender cannot make a reader allocate by claiming a large payload it + * did not send. + * + *Only the whole buffer read reports this, because only there does the + * envelope have to end where the input does. Reading one frame out of + * something longer, bytes after the signature are the next frame rather + * than a disagreement, and a frame that runs past the input is + * {@link #UNEXPECTED_END}. Every implementation draws the line in the + * same place, so this status means a declaration disagreeing with data + * that is all present, and nothing else.
+ */ + BYTE_COUNT_MISMATCH, + + /** + * The envelope is structurally consistent but larger than this runtime + * can hold. Deliberately apart from the data being wrong, because the + * same bytes may be readable elsewhere. + * + *Not reachable in Java, and so not covered by a test. A Java byte + * array cannot hold more than {@link Integer#MAX_VALUE} bytes, so a + * declaration larger than that can neither equal the bytes present, which + * is what the whole buffer read requires, nor be covered by them, which + * is what the framed read requires. The guard is kept so a future change + * to that arithmetic cannot silently truncate the declaration.
+ */ + IMPLEMENTATION_CAPACITY_EXCEEDED, + + /** + * The envelope is malformed in a way none of the others describes. A + * fallback for the genuinely unclassified, not a substitute for naming a + * failure that is already understood. + * + *Nothing produces one today, so no test can. The single place that + * reports it is the check that an envelope ended where the input did, + * which the framed read does not apply at all and which cannot fire on + * the whole buffer read while the declared payload count has already + * been required to leave exactly the signature. That check is kept as a + * backstop rather than removed, because a future change to the count + * arithmetic would otherwise start accepting bytes after the signature + * in silence. Loosening the count rule during a deliberate check of the + * tests made it fire, so the backstop does work.
+ */ + MALFORMED_ENVELOPE, + + /** + * The bytes are the marker for an absent optional OWID, being the single + * byte zero, so there is deliberately no identifier here. + * + *Not a failure and not an OWID. Version zero is supported and it + * means something, which is why this is not + * {@link #UNSUPPORTED_VERSION}, but what it means is that a node is + * missing, so no value is handed back. 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.
+ * + *The first byte settles this on both reading contracts, because + * nothing after it can turn the value into an OWID. Reading one frame + * out of something longer, the marker is consumed, so a caller walking a + * run of frames can step over an absent node and read the one after + * it.
+ */ + ABSENT_NODE +} diff --git a/src/main/java/com/swancommunity/owid/OwidReader.java b/src/main/java/com/swancommunity/owid/OwidReader.java new file mode 100644 index 0000000..d4d9633 --- /dev/null +++ b/src/main/java/com/swancommunity/owid/OwidReader.java @@ -0,0 +1,329 @@ +/* **************************************************************************** + * Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * ***************************************************************************/ + +package com.swancommunity.owid; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; + +/** + * Reads a complete OWID from a buffer, reporting why rather than throwing + * when the bytes are not one. + * + *The buffer is walked by index and every read is checked against what is + * left, so a malformed envelope is a comparison that fails rather than an + * exception that unwinds. That matters because the data comes from outside, + * where whoever is sending it chooses how often the read fails and how large + * each attempt is, and an exception for every attempt is a cost they + * choose.
+ * + *Nothing here calls the throwing code and catches it. The exception + * would still be built and unwound, so the cost would remain and only the + * surface would look different.
+ * + *The same walk serves both reading contracts, which differ in one place + * only. A whole buffer holds one envelope and nothing else, so the declared + * payload has to leave exactly the signature at the end and a byte after it + * belongs to no field. A frame is one envelope inside something longer, so + * the declared payload and the signature only have to be present, and what + * follows is the next frame rather than rubbish.
+ */ +final class OwidReader { + + private OwidReader() { + } + + /** + * Reads one OWID from the region of the buffer between the two offsets. + * + * @param framed false when the region holds one envelope and nothing + * else, so the envelope has to end where the region does, + * and true when the envelope is one frame inside something + * longer and whatever follows is the next frame + */ + static OwidParseResult read(byte[] buffer, int from, int total, + boolean framed) { + // Nothing supplied is not the same as data that stopped part way + // through a field, so a region with no bytes in it is reported as the + // absence it is rather than as a truncation. + if (buffer == null || total - from <= 0) { + return OwidParseResult.failed(OwidParseStatus.MISSING_INPUT); + } + + Version version = Version.forByte(buffer[from] & 0xFF); + if (version == Version.EMPTY) { + // The marker for an absent node, being the single byte zero. It + // is not an OWID and no value is handed back, because it carries + // no domain, no date and no signature and returning one would put + // an OWID in a caller's hands that nothing had ever signed. It is + // not a fault either, since version zero is supported and it + // means a node is missing, which is why the caller is told that + // rather than told the version is unknown. + // + // The first byte settles this on both contracts, because nothing + // after it can turn the value into an OWID, so a whole buffer + // that begins with the marker is reported as an absent node + // whatever else it carries. Reading a frame the marker is also + // consumed, so a caller walking a run of frames steps over the + // absent node and reads the one after it. + return OwidParseResult.absentNode(1); + } + if (version == null) { + // A version byte this implementation does not know, which is a + // different thing from the marker above, where the version is + // known and says a node is missing. + return OwidParseResult.failed( + OwidParseStatus.UNSUPPORTED_VERSION); + } + int at = from + 1; + + // The domain, terminated by a zero byte and no longer than the + // maximum published for a domain name. The walk stops at that + // maximum rather than at the end of the buffer, so a buffer whose + // terminator is missing costs no more than the maximum however long + // that buffer is. + int start = at; + int limit = Math.min(total, start + Io.MAXIMUM_DOMAIN_LENGTH + 1); + String domain = null; + while (at < limit) { + if (buffer[at] == 0) { + domain = new String(buffer, start, at - start, + StandardCharsets.UTF_8); + at++; + break; + } + at++; + } + if (domain == null) { + // Either the buffer ended inside the domain, or the domain ran + // past the maximum without terminating. The second is a domain + // that cannot be valid rather than data that merely stopped, so + // the two are reported differently. + if (at >= total && at - start <= Io.MAXIMUM_DOMAIN_LENGTH) { + return OwidParseResult.failed( + OwidParseStatus.UNEXPECTED_END); + } + return OwidParseResult.failed( + OwidParseStatus.INVALID_DOMAIN_ENCODING); + } + + // The date, whose width depends on the version. + Instant date; + if (version == Version.VERSION1) { + if (total - at < 2) { + return OwidParseResult.failed( + OwidParseStatus.UNEXPECTED_END); + } + long hours = ((long) (buffer[at] & 0xFF) << 8) + | (buffer[at + 1] & 0xFF); + at += 2; + date = Io.baseDate().plus(Duration.ofHours(hours)); + } else { + if (total - at < 4) { + return OwidParseResult.failed( + OwidParseStatus.UNEXPECTED_END); + } + long minutes = readUInt32(buffer, at); + at += 4; + date = Io.baseDate().plus(Duration.ofMinutes(minutes)); + } + + if (total - at < 4) { + return OwidParseResult.failed(OwidParseStatus.UNEXPECTED_END); + } + long declared = readUInt32(buffer, at); + at += 4; + + // The declaration is the sender's claim about a payload not yet + // read, so it is compared with what is actually present before + // anything is sized by it. The subtraction is done in a long, and + // the declaration is read as unsigned into a long, so a buffer with + // fewer bytes left than a signature needs gives a negative count + // rather than wrapping, and a negative count can never equal a + // declaration. + // + // Reading a whole buffer, the disagreement is the finding even when + // the buffer also stopped early. What a reader can say for certain is + // that the declared payload cannot leave exactly the signature the + // version requires, and that is true whichever way the bytes fall + // short. Reporting it as a truncation instead would name a different + // fault for the same evidence. + // + // Reading a frame, only a shortfall is a finding, since a longer + // input is the next frame rather than a disagreement. A frame that + // runs past what is here is reported as a truncation, because a + // caller walking a stream needs to know whether to wait for more + // bytes or to give up on these, and those are different answers. + long present = (long) (total - at) - Owid.SIGNATURE_LENGTH; + if (framed) { + if (present < declared) { + return OwidParseResult.failed( + OwidParseStatus.UNEXPECTED_END); + } + } else if (present != declared) { + return OwidParseResult.failed( + OwidParseStatus.BYTE_COUNT_MISMATCH); + } + + // The bytes are all here. Whether this runtime can hold them in one + // array is a separate question with a different answer, because the + // same envelope may be readable elsewhere. A Java byte array cannot + // exceed Integer.MAX_VALUE, so neither contract above can be + // satisfied by a larger declaration and this cannot fire today. It is + // kept so a future change to that arithmetic cannot silently truncate + // the cast below. + if (declared > Integer.MAX_VALUE) { + return OwidParseResult.failed( + OwidParseStatus.IMPLEMENTATION_CAPACITY_EXCEEDED); + } + + int payloadLength = (int) declared; + byte[] payload = new byte[payloadLength]; + System.arraycopy(buffer, at, payload, 0, payloadLength); + at += payloadLength; + + byte[] signature = new byte[Owid.SIGNATURE_LENGTH]; + System.arraycopy(buffer, at, signature, 0, Owid.SIGNATURE_LENGTH); + at += Owid.SIGNATURE_LENGTH; + + if (framed == false && at != total) { + // Unreachable while the count check above holds, and kept so + // that a future change to that arithmetic cannot silently start + // accepting bytes after the signature. A frame says nothing about + // what follows it, so the check does not apply there. + return OwidParseResult.failed( + OwidParseStatus.MALFORMED_ENVELOPE); + } + + return OwidParseResult.parsed( + new Owid(version, domain, date, payload, signature), + at - from); + } + + /** + * Decodes base 64 without throwing, returning null when the string is not + * base 64. + * + *Written out here rather than handed to {@code java.util.Base64} + * because neither JDK decoder answers the question this surface has to + * ask. The strict decoder throws, which is the cost this change exists to + * remove, and the MIME decoder silently drops every character outside the + * alphabet, so a string of nothing but rubbish would 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 standard alphabet is accepted with or without the trailing + * padding, because both are ordinary ways to carry an encoded OWID. + * Spaces, tabs and line breaks are skipped, since wrapped encodings are + * common and were accepted before. Anything else is refused.
+ */ + static byte[] decodeBase64(String value) { + int length = value.length(); + int significant = 0; + int padding = 0; + for (int i = 0; i < length; i++) { + char c = value.charAt(i); + if (isSkipped(c)) { + continue; + } + if (c == '=') { + padding++; + continue; + } + if (padding > 0 || c > 127 || DECODE[c] < 0) { + // Either a character outside the alphabet, or data after the + // padding that closes the last block. + return null; + } + significant++; + } + + // Padding only ever brings the final block up to four characters, so + // any other amount of it means the string was not produced by an + // encoder. + int remainder = significant % 4; + if (remainder == 1) { + return null; + } + if (padding > 0 && padding != (4 - remainder) % 4) { + return null; + } + + byte[] decoded = new byte[significant * 3 / 4]; + int bits = 0; + int held = 0; + int at = 0; + for (int i = 0; i < length; i++) { + char c = value.charAt(i); + if (c == '=' || isSkipped(c)) { + continue; + } + bits = (bits << 6) | DECODE[c]; + held++; + if (held == 4) { + decoded[at++] = (byte) (bits >> 16); + decoded[at++] = (byte) (bits >> 8); + decoded[at++] = (byte) bits; + bits = 0; + held = 0; + } + } + if (held == 2) { + decoded[at] = (byte) (bits >> 4); + } else if (held == 3) { + decoded[at++] = (byte) (bits >> 10); + decoded[at] = (byte) (bits >> 2); + } + return decoded; + } + + /** Layout whitespace, which an encoder may have used to wrap lines. */ + private static boolean isSkipped(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; + } + + /** + * The value of each standard alphabet character, and -1 for every other + * character below 128. + */ + private static final int[] DECODE = buildDecodeTable(); + + private static int[] buildDecodeTable() { + int[] table = new int[128]; + for (int i = 0; i < table.length; i++) { + table[i] = -1; + } + String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz0123456789+/"; + for (int i = 0; i < alphabet.length(); i++) { + table[alphabet.charAt(i)] = i; + } + return table; + } + + /** + * The four bytes at the offset as a little endian unsigned 32 bit value + * widened into a long, so the full wire range is compared without a + * signed int wrapping into a negative number. + */ + private static long readUInt32(byte[] buffer, int offset) { + return ((long) (buffer[offset] & 0xFF)) + | ((long) (buffer[offset + 1] & 0xFF) << 8) + | ((long) (buffer[offset + 2] & 0xFF) << 16) + | ((long) (buffer[offset + 3] & 0xFF) << 24); + } +} diff --git a/src/main/java/com/swancommunity/owid/OwidSignatureStatus.java b/src/main/java/com/swancommunity/owid/OwidSignatureStatus.java new file mode 100644 index 0000000..e24153e --- /dev/null +++ b/src/main/java/com/swancommunity/owid/OwidSignatureStatus.java @@ -0,0 +1,93 @@ +/* **************************************************************************** + * Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * ***************************************************************************/ + +package com.swancommunity.owid; + +/** + * The outcome of asking whether an OWID signature is genuine. + * + *Only two of these say anything about the signature itself. The rest say + * the question could not be answered, which is a different thing and must + * never be reported as a forgery. A key that cannot be obtained, a key that + * cannot be decoded, or a provider that fails leaves the signature unjudged, + * and a caller acting on "invalid" would reject good identifiers during an + * outage.
+ * + *That is not hypothetical. On 30 August 2026 the key end points served + * PEM a strict parser rejects and every offline verification against them + * failed while the keys and the identifiers were both fine. Reported as + * {@link #INVALID_KEY} that reads as the operational fault it was, whereas + * reported as {@link #SIGNATURE_INVALID} it would have read as an + * attack.
+ */ +public enum OwidSignatureStatus { + + /** The signature is genuine for this data and this key. */ + SIGNATURE_VALID, + + /** + * The signature is well formed and does not match, so the data does not + * belong to the key it claims. This is the only status that means the + * identifier should be distrusted. + */ + SIGNATURE_INVALID, + + /** + * A signature field of the wrong length reached a verification surface + * directly. Truncation in raw external input is a parse + * {@link OwidParseStatus#UNEXPECTED_END} or + * {@link OwidParseStatus#BYTE_COUNT_MISMATCH} instead, because there the + * envelope never formed. + * + *A consumer of this library cannot produce it, because both routes an + * OWID arrives by settle the signature at the length the version + * requires. It is kept, and tested from inside the library, because the + * status is part of the cross language vocabulary and other surfaces can + * be handed a signature field on its own.
+ */ + INVALID_SIGNATURE_LENGTH, + + /** + * No key was supplied, or the one supplied cannot verify. The signature + * was never examined. + */ + KEY_UNAVAILABLE, + + /** + * Key material arrived but cannot be decoded, imported, or used as the + * required type. The fault is in the key and not in the identifier. + */ + INVALID_KEY, + + /** + * The work required is more than this runtime can hold. + * + *Not covered by a test, because reaching it needs an OWID and its + * chain to approach the two gigabyte limit of a Java array, which cannot + * be built in a test suite that has to run on an ordinary machine. The + * path to it is real, being the overflow guard on the serialized length, + * which raises a distinct exception so this status does not have to be + * told apart from {@link #VERIFICATION_ERROR} by reading a message.
+ */ + IMPLEMENTATION_CAPACITY_EXCEEDED, + + /** + * The check could not be completed for a reason that is not the + * identifier's fault, such as a cryptographic provider failing on valid + * inputs or a field that cannot be encoded for signing. + */ + VERIFICATION_ERROR +} diff --git a/src/main/java/com/swancommunity/owid/OwidVerificationResult.java b/src/main/java/com/swancommunity/owid/OwidVerificationResult.java new file mode 100644 index 0000000..71c68ae --- /dev/null +++ b/src/main/java/com/swancommunity/owid/OwidVerificationResult.java @@ -0,0 +1,62 @@ +/* **************************************************************************** + * Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * ***************************************************************************/ + +package com.swancommunity.owid; + +/** + * What asking whether an OWID signature is genuine produced. + * + *{@link #isValid()} is true only when the signature was examined and + * matched. Everything else is false, but the reasons are not + * interchangeable, because "does not match" and "could not check" call for + * different handling and {@link #getStatus()} keeps them apart.
+ */ +public final class OwidVerificationResult { + + private final OwidSignatureStatus status; + + private OwidVerificationResult(OwidSignatureStatus status) { + this.status = status; + } + + /** The result carrying the status given. */ + static OwidVerificationResult of(OwidSignatureStatus status) { + return new OwidVerificationResult(status); + } + + /** + * Whether the signature was examined and found genuine. + * + * @return true only for {@link OwidSignatureStatus#SIGNATURE_VALID} + */ + public boolean isValid() { + return status == OwidSignatureStatus.SIGNATURE_VALID; + } + + /** + * The outcome of the check. + * + * @return the signature status + */ + public OwidSignatureStatus getStatus() { + return status; + } + + @Override + public String toString() { + return status.name(); + } +} diff --git a/src/main/java/com/swancommunity/owid/Version.java b/src/main/java/com/swancommunity/owid/Version.java index 35b368f..85f77ff 100644 --- a/src/main/java/com/swancommunity/owid/Version.java +++ b/src/main/java/com/swancommunity/owid/Version.java @@ -26,7 +26,16 @@ */ public enum Version { - /** Marker used to indicate an optional OWID that is not present. */ + /** + * Marker used to indicate an optional OWID that is not present, inside a + * larger framed byte array. + * + *No OWID carries this version, so reading the marker hands back no + * value and reports {@link OwidParseStatus#ABSENT_NODE}, being the + * absence of a node rather than a fault. Reading one frame out of + * something longer the marker is consumed, so a caller steps over the + * absent node and reads the frame after it.
+ */ EMPTY(0), /** @@ -70,19 +79,19 @@ public static Version current() { } /** - * Maps a byte to the matching version. - * - * @param value the version byte - * @return the matching version - * @throws OwidException if the byte is not a known version + * Maps a byte to the matching version, or null when the byte is not a + * known version. Reading a version the implementation does not know is + * an ordinary outcome for data that arrived from outside, so it is + * answered rather than thrown, and the caller reports it as + * {@link OwidParseStatus#UNSUPPORTED_VERSION}. */ - public static Version fromByte(int value) throws OwidException { + static Version forByte(int value) { int unsigned = value & 0xFF; for (Version version : values()) { if (version.value == unsigned) { return version; } } - throw new OwidException("OWID version '" + unsigned + "' not supported"); + return null; } } diff --git a/src/test/java/com/example/owidconsumer/ConstructionBoundaryTest.java b/src/test/java/com/example/owidconsumer/ConstructionBoundaryTest.java new file mode 100644 index 0000000..0a40473 --- /dev/null +++ b/src/test/java/com/example/owidconsumer/ConstructionBoundaryTest.java @@ -0,0 +1,176 @@ +/* **************************************************************************** + * Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * ***************************************************************************/ + +package com.example.owidconsumer; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.swancommunity.owid.Creator; +import com.swancommunity.owid.Crypto; +import com.swancommunity.owid.Owid; +import com.swancommunity.owid.OwidException; +import com.swancommunity.owid.OwidParseResult; +import com.swancommunity.owid.OwidParseStatus; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +/** + * What a library user outside the OWID package can and cannot do. + * + *This test lives in another package on purpose. The tests that sit beside + * the library share its package, so the compiler lets them reach things a + * consumer cannot, and a construction boundary asserted from inside would + * measure nothing. Everything here goes through the same public surface a + * consumer compiles against.
+ */ +class ConstructionBoundaryTest { + + /** + * There is no public constructor, so no caller can name one. This is the + * compiler's own rule rather than a check made at run time, and the + * reflective attempt below shows the runtime refuses it as well. + */ + @Test + void owidHasNoPublicConstructor() { + assertEquals(0, Owid.class.getConstructors().length, + "an OWID should not be constructible by a caller"); + for (Constructor> constructor + : Owid.class.getDeclaredConstructors()) { + int modifiers = constructor.getModifiers(); + assertTrue(Modifier.isPublic(modifiers) == false + && Modifier.isProtected(modifiers) == false, + "every OWID constructor should be package private"); + } + } + + /** + * The runtime refuses the constructor as well, so the boundary is not + * only a compile time one. Reflection with setAccessible could still + * reach it, which is true of every package private member in Java and is + * the honest limit of the mechanism. + */ + @Test + void reflectiveConstructionIsRefused() { + for (Constructor> constructor + : Owid.class.getDeclaredConstructors()) { + Object[] arguments = new Object[constructor.getParameterCount()]; + assertThrows(IllegalAccessException.class, + () -> constructor.newInstance(arguments), + "the runtime should refuse a package private constructor"); + } + } + + /** No field can be set or rebound from outside. */ + @Test + void owidHasNoPublicMutation() { + for (Method method : Owid.class.getMethods()) { + assertTrue(method.getName().startsWith("set") == false, + "an OWID should have no setter, but has " + + method.getName()); + } + for (Field field : Owid.class.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + assertTrue(Modifier.isPrivate(field.getModifiers()), + "every OWID field should be private, but " + + field.getName() + " is not"); + assertTrue(Modifier.isFinal(field.getModifiers()), + "every OWID field should be final, but " + + field.getName() + " is not"); + } + } + + /** + * There is no public way to sign an OWID either, because with no way to + * obtain an unsigned one there is nothing outside to sign, and signing a + * parsed one again would replace the signature its fields were read with. + */ + @Test + void creatorHasNoPublicSigningOfAnOwid() { + for (Method method : Creator.class.getMethods()) { + if (method.getName().startsWith("sign") == false) { + continue; + } + fail("a creator should not sign a caller's OWID, but exposes " + + method.getName()); + } + } + + /** + * Writing into a byte array a caller was handed does not alter the OWID, + * because a Java array is mutable and the OWID hands out copies. + */ + @Test + void writingIntoReturnedArraysDoesNotAlterTheOwid() throws OwidException { + Crypto crypto = Crypto.generate(); + Creator creator = Creator.create("example.com", crypto); + Owid owid = creator.createBytes(new byte[] {1, 2, 3}); + byte[] encoded = owid.asByteArray(); + + byte[] payload = owid.getPayload(); + byte[] signature = owid.getSignature(); + payload[0] = 99; + signature[0] ^= 0xFF; + + assertArrayEquals(new byte[] {1, 2, 3}, owid.getPayload(), + "the payload should be unchanged"); + assertNotEquals(99, owid.getPayload()[0], + "writing into the copy should not reach the OWID"); + assertArrayEquals(encoded, owid.asByteArray(), + "the OWID should serialise to the same bytes"); + assertTrue(owid.verifyWithCrypto(crypto, Collections.A documented example that nothing compiles goes stale without anyone + * noticing, and in the Go port exactly that had already happened. Keeping the + * example here means a change to the library that would break it breaks the + * build instead.
+ * + *The bodies below are the README text, with the assertions of a test + * added around it. Change one and change the other.
+ */ +class ReadmeExampleTest { + + @Test + void createSerializeReadBackAndVerify() throws OwidException { + // The creator operates a domain and holds the signing keys. + Crypto crypto = Crypto.generate(); + Creator creator = Creator.create("example.com", crypto); + + // Create a signed OWID with a payload. An OWID is signed from the + // moment it exists, so there is never an unsigned one to hold. + Owid owid = creator.createString("Hello World"); + + // Serialize to base 64 for storage or transmission. + String encoded = owid.asBase64(); + + // Later, or elsewhere, read it back. Reading answers rather than + // throwing, because whatever arrives from outside may not be an OWID + // at all. + OwidParseResult result = Owid.parse(encoded); + if (result.isSuccess()) { + Owid copy = result.getValue(); + String publicPem = crypto.publicKeyPem(); + boolean valid = copy.verifyWithPublicKey( + publicPem, Collections.The bytes are written by hand rather than through the library, so a test + * of the reader does not depend on the writer it is checking the reader + * against, and so a test can make the declared payload length and the bytes + * present disagree in ways the writer would never produce.
+ */ +final class Envelope { + + /** The domain used wherever a test does not care what the domain is. */ + static final String DOMAIN = "51d.es"; + + private Envelope() { + } + + /** A byte array of the length given, every byte set to the value. */ + static byte[] filled(int length, byte value) { + byte[] bytes = new byte[length]; + Arrays.fill(bytes, value); + return bytes; + } + + /** + * A signature shaped run of bytes, being the right length and nothing + * more. + */ + static byte[] signature() { + return filled(Owid.SIGNATURE_LENGTH, (byte) 0x99); + } + + /** + * A version 3 envelope, being the version byte, the domain with its + * terminator, four minute bytes, the declared payload length, the payload + * bytes given and the signature bytes given. + */ + static byte[] version3(String domain, long minutes, long declaredLength, + byte[] payload, byte[] signature) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + stream.write(Version.VERSION3.asByte()); + writeDomain(stream, domain.getBytes(StandardCharsets.UTF_8)); + writeLittleEndian(stream, minutes); + writeLittleEndian(stream, declaredLength); + stream.write(payload, 0, payload.length); + stream.write(signature, 0, signature.length); + return stream.toByteArray(); + } + + /** The smallest well formed version 3 envelope carrying the payload. */ + static byte[] version3(byte[] payload) { + return version3(DOMAIN, 1000L, payload.length, payload, signature()); + } + + /** + * A version 1 envelope, whose date is two big endian bytes counting hours + * rather than four little endian bytes counting minutes. + */ + static byte[] version1(String domain, long hours, long declaredLength, + byte[] payload, byte[] signature) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + stream.write(Version.VERSION1.asByte()); + writeDomain(stream, domain.getBytes(StandardCharsets.UTF_8)); + stream.write((int) ((hours >> 8) & 0xFF)); + stream.write((int) (hours & 0xFF)); + writeLittleEndian(stream, declaredLength); + stream.write(payload, 0, payload.length); + stream.write(signature, 0, signature.length); + return stream.toByteArray(); + } + + private static void writeDomain(ByteArrayOutputStream stream, + byte[] domain) { + stream.write(domain, 0, domain.length); + stream.write(0); + } + + static void writeLittleEndian(ByteArrayOutputStream stream, long value) { + stream.write((int) (value & 0xFF)); + stream.write((int) ((value >> 8) & 0xFF)); + stream.write((int) ((value >> 16) & 0xFF)); + stream.write((int) ((value >> 24) & 0xFF)); + } +} diff --git a/src/test/java/com/swancommunity/owid/FixturesTest.java b/src/test/java/com/swancommunity/owid/FixturesTest.java index 8134910..9b94cb5 100644 --- a/src/test/java/com/swancommunity/owid/FixturesTest.java +++ b/src/test/java/com/swancommunity/owid/FixturesTest.java @@ -133,22 +133,22 @@ private void runFixtures(Fixtures fixtures) throws OwidException { Crypto crypto = Crypto.newVerifyOnly(fixtures.spki()); ListThe framed read differs from the whole buffer read in one place. A whole + * buffer has to end where the envelope does, so a byte after the signature + * belongs to no field, whereas a frame only requires the declared payload and + * the signature to be present and says nothing about what follows, because + * what follows is the next frame rather than rubbish.
+ */ +class FramedReadTest { + + /** The two envelopes used throughout, with payloads that differ. */ + private static final byte[] FIRST = + Envelope.version3("first.example", 1000L, 3, + new byte[] {1, 2, 3}, Envelope.signature()); + + private static final byte[] SECOND = + Envelope.version3("second.example", 2000L, 5, + new byte[] {4, 5, 6, 7, 8}, Envelope.signature()); + + private static byte[] concatenated() { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + stream.write(FIRST, 0, FIRST.length); + stream.write(SECOND, 0, SECOND.length); + return stream.toByteArray(); + } + + /** + * Two complete envelopes one after the other read as two OWIDs from the + * same input, and between them they account for every byte. + */ + @Test + void twoEnvelopesReadOneAfterTheOther() { + ByteBuffer buffer = ByteBuffer.wrap(concatenated()); + + ListThe status is a truncation rather than a byte count disagreement, + * 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.
+ */ + @Test + void truncatedFrameIsRefusedAndConsumesNothing() { + byte[] cutShort = Arrays.copyOf(FIRST, FIRST.length - 1); + ByteBuffer buffer = ByteBuffer.wrap(cutShort); + + ParseAssert.failed(Owid.parse(buffer), OwidParseStatus.UNEXPECTED_END); + + assertEquals(0, buffer.position(), + "a failed read should leave the buffer where it was"); + assertEquals(cutShort.length, buffer.remaining(), + "a failed read should consume nothing"); + } + + /** + * A frame that is good but is followed by a bad one leaves the good one + * read and the buffer sitting at the start of the bad one, which is what + * lets a caller decide what to do about it. + */ + @Test + void aBadFrameLeavesThePositionAtItsStart() { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + stream.write(FIRST, 0, FIRST.length); + byte[] rubbish = {0x63, 0x63, 0x63}; + stream.write(rubbish, 0, rubbish.length); + ByteBuffer buffer = ByteBuffer.wrap(stream.toByteArray()); + + ParseAssert.parsed(Owid.parse(buffer)); + assertEquals(FIRST.length, buffer.position(), + "should have read the good frame"); + + ParseAssert.failed(Owid.parse(buffer), + OwidParseStatus.UNSUPPORTED_VERSION); + assertEquals(FIRST.length, buffer.position(), + "should leave the buffer at the start of the bad frame"); + } + + /** + * The framed read reports the same vocabulary as everything else, and it + * is reachable through the public surface rather than only from inside. + */ + @Test + void framedFailuresUseTheSharedVocabulary() { + // A version this implementation does not know. + byte[] badVersion = FIRST.clone(); + badVersion[0] = 0x04; + ParseAssert.failed(Owid.parse(ByteBuffer.wrap(badVersion)), + OwidParseStatus.UNSUPPORTED_VERSION); + + // A domain that runs past the published maximum before terminating. + StringBuilder tooLong = new StringBuilder(); + while (tooLong.length() <= Io.MAXIMUM_DOMAIN_LENGTH) { + tooLong.append('a'); + } + ParseAssert.failed( + Owid.parse(ByteBuffer.wrap( + Envelope.version3(tooLong.toString(), 1000L, 0, + new byte[0], Envelope.signature()))), + OwidParseStatus.INVALID_DOMAIN_ENCODING); + + // Nothing left to read. + ParseAssert.failed(Owid.parse(ByteBuffer.wrap(new byte[0])), + OwidParseStatus.MISSING_INPUT); + ParseAssert.failed(Owid.parse((ByteBuffer) null), + OwidParseStatus.MISSING_INPUT); + + // A declaration far larger than the bytes supplied. + // PayloadLengthTest measures that nothing is sized by it. + ParseAssert.failed( + Owid.parse(ByteBuffer.wrap( + Envelope.version3(Envelope.DOMAIN, 1000L, 0xFFFFFFFFL, + new byte[0], Envelope.signature()))), + OwidParseStatus.UNEXPECTED_END); + + } + + /** + * The marker for an absent node hands back no OWID, says so in its own + * words, and takes the one byte it is, so a caller walking a run of + * frames can step over a node that is deliberately not there. + */ + @Test + void anAbsentNodeIsSteppedOverAndTheNextFrameRead() { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + byte[] marker = Owid.emptyByteArray(); + stream.write(marker, 0, marker.length); + stream.write(FIRST, 0, FIRST.length); + ByteBuffer buffer = ByteBuffer.wrap(stream.toByteArray()); + + ParseAssert.absentNode(Owid.parse(buffer)); + assertEquals(marker.length, buffer.position(), + "should have stepped over the marker and nothing more"); + + Owid owid = ParseAssert.parsed(Owid.parse(buffer)); + + assertEquals("first.example", owid.getDomain(), + "should read the frame that follows the absent node"); + assertFalse(buffer.hasRemaining(), + "the marker and the frame should account for every byte"); + } + + /** + * A marker on its own, and a run of them, read as absent nodes rather + * than as anything wrong. + */ + @Test + void aRunOfAbsentNodesReadsOneAtATime() { + ByteBuffer buffer = ByteBuffer.wrap(new byte[] {0, 0, 0}); + + int absent = 0; + while (buffer.hasRemaining()) { + ParseAssert.absentNode(Owid.parse(buffer)); + absent++; + } + + assertEquals(3, absent, "should have read three absent nodes"); + } + + /** + * A frame read from a buffer that starts part way through an array, which + * is what a caller slicing a larger record hands over. + */ + @Test + void aBufferThatStartsPartWayThroughAnArrayReads() { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + byte[] prefix = {(byte) 0xEE, (byte) 0xEE}; + stream.write(prefix, 0, prefix.length); + stream.write(FIRST, 0, FIRST.length); + ByteBuffer buffer = ByteBuffer.wrap(stream.toByteArray()); + ((Buffer) buffer).position(prefix.length); + ByteBuffer sliced = buffer.slice(); + + Owid owid = ParseAssert.parsed(Owid.parse(sliced)); + + assertEquals("first.example", owid.getDomain(), + "should read the frame from where the slice starts"); + assertFalse(sliced.hasRemaining(), + "should have consumed the whole slice"); + } + + /** + * A direct buffer has no array to walk, so the bytes are taken a copy of. + * The answer has to be the same one. + */ + @Test + void aDirectBufferReadsTheSame() { + byte[] bytes = concatenated(); + ByteBuffer direct = ByteBuffer.allocateDirect(bytes.length); + direct.put(bytes); + ((Buffer) direct).flip(); + assertFalse(direct.hasArray(), + "the test needs a buffer with no array behind it"); + + Owid first = ParseAssert.parsed(Owid.parse(direct)); + assertEquals(FIRST.length, direct.position(), + "should move a direct buffer on as well"); + Owid second = ParseAssert.parsed(Owid.parse(direct)); + + assertEquals("first.example", first.getDomain(), + "should read the first domain from a direct buffer"); + assertEquals("second.example", second.getDomain(), + "should read the second domain from a direct buffer"); + assertFalse(direct.hasRemaining(), + "should have consumed the whole direct buffer"); + } + + /** + * A read only buffer has no array a caller may reach either, and must + * read the same way and stay read only. + */ + @Test + void aReadOnlyBufferReadsTheSame() { + ByteBuffer readOnly = ByteBuffer.wrap(FIRST).asReadOnlyBuffer(); + assertTrue(readOnly.isReadOnly(), "the buffer should be read only"); + + Owid owid = ParseAssert.parsed(Owid.parse(readOnly)); + + assertEquals("first.example", owid.getDomain(), + "should read from a read only buffer"); + assertFalse(readOnly.hasRemaining(), + "should have consumed the whole buffer"); + } + + /** + * The whole buffer read reports the length of the envelope too, which + * there is the whole of the buffer. + */ + @Test + void theWholeBufferReadAlsoReportsTheEnvelopeLength() { + OwidParseResult result = Owid.parse(FIRST); + + ParseAssert.parsed(result); + assertEquals(FIRST.length, result.getByteCount(), + "the envelope should be the whole of the buffer"); + assertEquals(0, Owid.parse(new byte[] {0x04}).getByteCount(), + "a read that failed should report consuming nothing"); + } +} diff --git a/src/test/java/com/swancommunity/owid/IoTest.java b/src/test/java/com/swancommunity/owid/IoTest.java index 18c4c20..8660953 100644 Binary files a/src/test/java/com/swancommunity/owid/IoTest.java and b/src/test/java/com/swancommunity/owid/IoTest.java differ diff --git a/src/test/java/com/swancommunity/owid/ParseAssert.java b/src/test/java/com/swancommunity/owid/ParseAssert.java new file mode 100644 index 0000000..0076b8a --- /dev/null +++ b/src/test/java/com/swancommunity/owid/ParseAssert.java @@ -0,0 +1,89 @@ +/* **************************************************************************** + * Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * ***************************************************************************/ + +package com.swancommunity.owid; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Assertions over a parse result, used everywhere a test reads an OWID. + * + *Each one checks all three facts a result reports rather than only the + * one the test is interested in, so no test can pass while the result is + * internally inconsistent.
+ */ +final class ParseAssert { + + private ParseAssert() { + } + + /** + * Asserts the read worked and returns the OWID, checking that success, + * the value and the status agree. + */ + static Owid parsed(OwidParseResult result) { + assertNotNull(result, "a read should always report a result"); + assertTrue(result.isSuccess(), + "should have read an OWID but reported " + result.getStatus()); + assertEquals(OwidParseStatus.PARSED, result.getStatus(), + "a successful read should report PARSED"); + assertNotNull(result.getValue(), + "a successful read should hand back the OWID"); + assertTrue(result.getByteCount() > 0, + "a successful read should report the bytes it consumed"); + return result.getValue(); + } + + /** + * Asserts the read failed for the reason given, and that nothing was + * handed back with it. + */ + static void failed(OwidParseResult result, OwidParseStatus expected) { + assertNotNull(result, "a read should always report a result"); + assertFalse(result.isSuccess(), + "should have refused the input but reported success"); + assertEquals(expected, result.getStatus(), + "should report the reason the input is not an OWID"); + assertNull(result.getValue(), + "a failed read should hand back no OWID"); + // The framed read moves the buffer on by this much, so a failure + // reporting anything other than nothing would leave a caller part way + // through a frame it could not reason about. + assertEquals(0, result.getByteCount(), + "a failed read should report consuming nothing"); + } + + /** + * Asserts the bytes were the marker for an absent node, that nothing was + * handed back for it, and that it occupied the single byte a caller has + * to step over to reach the next frame. + */ + static void absentNode(OwidParseResult result) { + assertNotNull(result, "a read should always report a result"); + assertFalse(result.isSuccess(), + "the marker for an absent node is not an OWID"); + assertEquals(OwidParseStatus.ABSENT_NODE, result.getStatus(), + "should report the absence of a node"); + assertNull(result.getValue(), + "the marker should hand back no OWID"); + assertEquals(1, result.getByteCount(), + "the marker should occupy the one byte it is"); + } +} diff --git a/src/test/java/com/swancommunity/owid/ParseContractTest.java b/src/test/java/com/swancommunity/owid/ParseContractTest.java new file mode 100644 index 0000000..930cec3 --- /dev/null +++ b/src/test/java/com/swancommunity/owid/ParseContractTest.java @@ -0,0 +1,371 @@ +/* **************************************************************************** + * Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * ***************************************************************************/ + +package com.swancommunity.owid; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.Random; +import org.junit.jupiter.api.Test; + +/** + * The contract the reading surfaces keep for data that arrived from outside. + * + *Every read reports three facts, being whether it worked, the OWID only + * when it did, and a named reason either way, and no expected failure throws. + * {@link ParseAssert} checks all three on every case here, so a test cannot + * pass while the result contradicts itself.
+ * + *These are the whole buffer surfaces, being the encoded string and the + * byte array. The framed surface, which reads one envelope out of something + * longer, is covered by {@link FramedReadTest}.
+ * + *Every member of {@link OwidParseStatus} is exercised here, with the + * domain cases also covered in more depth by {@link DomainLengthTest} and the + * byte count cases by {@link PayloadLengthTest}. Three members are not, and + * cannot be, being {@link OwidParseStatus#INVALID_INPUT_TYPE}, which the + * compiler already refuses, + * {@link OwidParseStatus#IMPLEMENTATION_CAPACITY_EXCEEDED}, which a Java byte + * array cannot reach on either surface, and + * {@link OwidParseStatus#MALFORMED_ENVELOPE}, which the byte count rule + * already makes unreachable and which is kept only as a backstop. The reason + * is recorded on each of those members as well.
+ */ +class ParseContractTest { + + /** The bytes of a well formed version 3 envelope with a short payload. */ + private static byte[] wellFormed() { + return Envelope.version3(new byte[] {1, 2, 3}); + } + + /** + * A successful read reports all three facts, being success, a value, and + * the reason PARSED. + */ + @Test + void successReportsAllThreeFacts() { + OwidParseResult result = Owid.parse(wellFormed()); + + assertTrue(result.isSuccess(), "should report success"); + assertNotNull(result.getValue(), "should hand back the OWID"); + assertEquals(OwidParseStatus.PARSED, result.getStatus(), + "should report PARSED"); + assertEquals(Envelope.DOMAIN, result.getValue().getDomain(), + "should read the fields"); + } + + /** Having nothing to say is allowed, so an empty payload is an OWID. */ + @Test + void emptyPayloadParses() { + Owid owid = ParseAssert.parsed( + Owid.parse(Envelope.version3(new byte[0]))); + + assertEquals(0, owid.getPayloadLength(), + "should read an empty payload"); + } + + /** + * A one mebibyte payload is an OWID. The limit the format sets is the + * wire format's, and how much an application accepts is that + * application's policy rather than the parser's. + */ + @Test + void oneMebibytePayloadParsesFromBase64() { + byte[] payload = Envelope.filled(1024 * 1024, (byte) 0x5A); + String encoded = Base64.getEncoder().encodeToString( + Envelope.version3(payload)); + + Owid owid = ParseAssert.parsed(Owid.parse(encoded)); + + assertEquals(payload.length, owid.getPayloadLength(), + "should read the whole payload"); + assertArrayEquals(payload, owid.getPayload(), + "should read the payload unchanged"); + } + + /** Nothing to read is its own answer, on both surfaces. */ + @Test + void absentInputIsMissingInput() { + ParseAssert.failed(Owid.parse((String) null), + OwidParseStatus.MISSING_INPUT); + ParseAssert.failed(Owid.parse(""), OwidParseStatus.MISSING_INPUT); + ParseAssert.failed(Owid.parse((byte[]) null), + OwidParseStatus.MISSING_INPUT); + ParseAssert.failed(Owid.parse(new byte[0]), + OwidParseStatus.MISSING_INPUT); + } + + /** + * Text that is not base 64 is reported rather than thrown, whether the + * characters are outside the alphabet, data follows the padding, or the + * length cannot be a whole number of blocks. + */ + @Test + void invalidBase64IsReported() { + String[] values = { + "not base 64 at all!", + "AAAAA*AA", + "AAAAA", + "AAAA=AAA", + "AAA==", + }; + for (String value : values) { + OwidParseResult result = assertDoesNotThrow( + () -> Owid.parse(value), + "should not throw for a value that is not base 64"); + ParseAssert.failed(result, OwidParseStatus.INVALID_BASE64); + } + } + + /** + * Base 64 without the trailing padding is a normal way to carry an + * encoded OWID, so it is read rather than refused. + */ + @Test + void unpaddedBase64IsAccepted() { + String padded = Base64.getEncoder().encodeToString(wellFormed()); + String unpadded = padded.replace("=", ""); + + Owid fromPadded = ParseAssert.parsed(Owid.parse(padded)); + Owid fromUnpadded = ParseAssert.parsed(Owid.parse(unpadded)); + + assertEquals(fromPadded, fromUnpadded, + "padding should make no difference to what is read"); + } + + /** A version byte this implementation does not know is named as such. */ + @Test + void unknownVersionIsReported() { + byte[] bytes = wellFormed(); + bytes[0] = 0x04; + + ParseAssert.failed(Owid.parse(bytes), + OwidParseStatus.UNSUPPORTED_VERSION); + } + + /** + * Data that stops inside a field, before the payload length has even been + * read, is a truncation rather than a byte count disagreement. + */ + @Test + void truncatedFieldsAreUnexpectedEnd() { + byte[] complete = wellFormed(); + int domainEnd = 1 + Envelope.DOMAIN.length() + 1; + + // Inside the domain, with no terminator reached. + ParseAssert.failed( + Owid.parse(Arrays.copyOf(complete, domainEnd - 2)), + OwidParseStatus.UNEXPECTED_END); + + // Inside the date, two of its four bytes present. + ParseAssert.failed( + Owid.parse(Arrays.copyOf(complete, domainEnd + 2)), + OwidParseStatus.UNEXPECTED_END); + + // Inside the payload length field, two of its four bytes present. + ParseAssert.failed( + Owid.parse(Arrays.copyOf(complete, domainEnd + 6)), + OwidParseStatus.UNEXPECTED_END); + } + + /** + * A domain that never terminates, or runs past the published maximum + * before it does, is a domain that cannot be valid rather than data that + * merely stopped. {@link DomainLengthTest} covers the bound itself and + * the cost of refusing a hostile field. + */ + @Test + void badDomainIsInvalidDomainEncoding() { + // Terminated, but longer than a domain name is allowed to be. + StringBuilder tooLong = new StringBuilder(); + while (tooLong.length() <= Io.MAXIMUM_DOMAIN_LENGTH) { + tooLong.append('a'); + } + ParseAssert.failed( + Owid.parse(Envelope.version3(tooLong.toString(), 1000L, 0, + new byte[0], Envelope.signature())), + OwidParseStatus.INVALID_DOMAIN_ENCODING); + + // Never terminated, in a buffer long enough that the walk has to stop + // itself rather than run out of bytes. + byte[] unterminated = Envelope.filled(64 * 1024, (byte) 'a'); + unterminated[0] = Version.VERSION3.asByte(); + ParseAssert.failed(Owid.parse(unterminated), + OwidParseStatus.INVALID_DOMAIN_ENCODING); + } + + /** + * A declared payload count that disagrees with the bytes present is + * refused before anything is sized by it. {@link PayloadLengthTest} + * covers the counts in every direction and proves nothing is allocated. + */ + @Test + void disagreeingByteCountIsByteCountMismatch() { + byte[] complete = wellFormed(); + byte[] longer = Arrays.copyOf(complete, complete.length + 1); + + ParseAssert.failed(Owid.parse(longer), + OwidParseStatus.BYTE_COUNT_MISMATCH); + } + + /** + * The marker for an absent optional OWID hands back no OWID, which is the + * thing that matters, and says what it is rather than calling it a fault. + * + *It carries no domain, no date and no signature, so handing one back + * would put an OWID in a caller's hands that nothing had ever signed, + * which is the state the construction boundary exists to prevent. Version + * zero is supported and it means something, though, so the caller is told + * that a node is absent rather than that the version is unknown.
+ * + *The first byte settles this on both reading contracts, because + * nothing after it can turn the value into an OWID, so a whole buffer + * beginning with the marker is an absent node whatever else it carries. + * That is the answer every OWID implementation gives.
+ */ + @Test + void emptyMarkerIsAnAbsentNodeAndNotAnOwid() { + ParseAssert.absentNode(Owid.parse(Owid.emptyByteArray())); + ParseAssert.absentNode(Owid.parse("AA==")); + + ParseAssert.absentNode(Owid.parse(new byte[] {0, 1})); + } + + /** + * Parsing and verifying are two questions with two answers. An identifier + * whose bytes are a well formed OWID reads, and only then does asking + * about the signature report that it does not match. + */ + @Test + void structurallyValidWithWrongSignatureParsesThenFailsVerification() + throws OwidException { + Crypto crypto = Crypto.generate(); + Creator creator = Creator.create("example.com", crypto); + byte[] bytes = creator.createBytes(new byte[] {1, 2, 3}).asByteArray(); + + // A payload byte, so the envelope stays exactly the shape it was and + // only the signature stops describing the contents. + bytes[bytes.length - Owid.SIGNATURE_LENGTH - 1] ^= 0x01; + + Owid owid = ParseAssert.parsed(Owid.parse(bytes)); + + OwidVerificationResult verification = + owid.verify(crypto, Collections.A key that cannot be obtained or cannot be decoded leaves the signature + * unjudged. Reporting that as invalid would tell a caller an identifier had + * been tampered with when all that happened is an outage, and a caller acting + * on it would reject good identifiers.
+ * + *Every member of {@link OwidSignatureStatus} is exercised here except + * {@link OwidSignatureStatus#IMPLEMENTATION_CAPACITY_EXCEEDED}, which needs + * an OWID and its chain to approach the two gigabyte limit of a Java array + * and so cannot be built in a suite that has to run on an ordinary machine. + * The reason is recorded on the member itself as well.
+ */ +class SignatureStatusTest { + + private static final ListThe OWID is built here through the package private constructor, + * because a consumer cannot produce one: both routes an OWID arrives by, + * being a read and a creator, settle the signature at 64 bytes. The + * status is part of the cross language vocabulary and other surfaces can + * be handed a signature field on its own, so the branch is exercised from + * inside the package where it can be reached.
+ */ + @Test + void wrongLengthSignatureIsInvalidSignatureLength() throws OwidException { + Owid noSignature = new Owid(Version.current(), "example.com", + Io.baseDate(), new byte[0], new byte[0]); + Owid shortSignature = new Owid(Version.current(), "example.com", + Io.baseDate(), new byte[0], + Envelope.filled(Owid.SIGNATURE_LENGTH - 1, (byte) 1)); + + assertEquals(OwidSignatureStatus.INVALID_SIGNATURE_LENGTH, + noSignature.verify(crypto(), NONE).getStatus(), + "no signature is not the same as a signature that is wrong"); + assertEquals(OwidSignatureStatus.INVALID_SIGNATURE_LENGTH, + shortSignature.verify(crypto(), NONE).getStatus(), + "a 63 byte signature is not a signature that is wrong"); + } + + /** + * A field that cannot be encoded stops the check before any cryptography, + * and that is the library failing rather than the identifier being wrong. + * Only the library can produce this, because a caller cannot build an + * OWID with a domain of its own choosing. + */ + @Test + void unencodableFieldIsVerificationError() throws OwidException { + StringBuilder domain = new StringBuilder(); + while (domain.length() <= Io.MAXIMUM_DOMAIN_LENGTH) { + domain.append('a'); + } + Owid owid = new Owid(Version.current(), domain.toString(), + Io.baseDate(), new byte[0], + Envelope.filled(Owid.SIGNATURE_LENGTH, (byte) 1)); + + assertEquals(OwidSignatureStatus.VERIFICATION_ERROR, + owid.verify(crypto(), NONE).getStatus(), + "a field that cannot be encoded is not an invalid signature"); + } + + /** + * The boolean surfaces still answer the same question for callers that + * only need yes or no, and still raise for a key they cannot import + * rather than answering no. + */ + @Test + void booleanSurfacesKeepTheirBehaviour() throws OwidException { + Crypto crypto = crypto(); + Owid owid = Creator.create("example.com", crypto) + .createString("payload"); + + assertTrue(owid.verifyWithCrypto(crypto, NONE), + "a genuine signature should verify"); + assertTrue(owid.verifyWithPublicKey(crypto.publicKeyPem(), NONE), + "a genuine signature should verify through the PEM"); + assertFalse(owid.verifyWithCrypto(crypto(), NONE), + "a signature checked against another key should not verify"); + } +} diff --git a/src/test/java/com/swancommunity/owid/WireVectorsTest.java b/src/test/java/com/swancommunity/owid/WireVectorsTest.java index 863fd3b..917afd8 100644 --- a/src/test/java/com/swancommunity/owid/WireVectorsTest.java +++ b/src/test/java/com/swancommunity/owid/WireVectorsTest.java @@ -54,10 +54,24 @@ private static byte[] decode(String value) { return Base64.getMimeDecoder().decode(value); } + /** + * Each vector also reads straight from its unpadded base 64 form, so the + * library's own decoder is shown to accept what the vectors carry. + */ + @Test + void vectorsReadFromUnpaddedBase64() { + for (String vector : new String[] {CREATOR, SUPPLIER, BAD}) { + Owid owid = ParseAssert.parsed(Owid.parse(vector)); + assertArrayEquals(decode(vector), + assertDoesNotThrow(owid::asByteArray), + "should read the same bytes from the encoded form"); + } + } + @Test void creatorRoundTripsByteExact() throws OwidException { byte[] original = decode(CREATOR); - Owid owid = Owid.fromByteArray(original); + Owid owid = ParseAssert.parsed(Owid.parse(original)); assertEquals("51db.uk", owid.getDomain(), "should read the domain"); assertEquals(Version.VERSION2, owid.getVersion(), "should read version 2"); @@ -70,7 +84,7 @@ void creatorRoundTripsByteExact() throws OwidException { @Test void supplierRoundTripsByteExact() throws OwidException { byte[] original = decode(SUPPLIER); - Owid owid = Owid.fromByteArray(original); + Owid owid = ParseAssert.parsed(Owid.parse(original)); assertEquals("pop-up.swan-demo.uk", owid.getDomain(), "should read the domain"); assertArrayEquals(new byte[] {0x01, 0x03}, owid.getPayload(), @@ -86,8 +100,7 @@ void supplierRoundTripsByteExact() throws OwidException { @Test void badParsesAndRoundTrips() throws OwidException { byte[] original = decode(BAD); - Owid owid = assertDoesNotThrow(() -> Owid.fromByteArray(original), - "the bad fixture should still parse"); + Owid owid = ParseAssert.parsed(Owid.parse(original)); assertEquals("badssp.swan-demo.uk", owid.getDomain(), "should read the domain"); assertArrayEquals(original, owid.asByteArray(),